ÿØÿà 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-Ї Ç PK9B\N autosave.ajax.phpnu[. /** * Thisscript processes ajax auto-save requests during the quiz. * * @package mod_quiz * @copyright 2013 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ define('AJAX_SCRIPT', true); require_once(__DIR__ . '/../../config.php'); require_once($CFG->dirroot . '/mod/quiz/locallib.php'); // Remember the current time as the time any responses were submitted // (so as to make sure students don't get penalized for slow processing on this page). $timenow = time(); require_sesskey(); // Get submitted parameters. $attemptid = required_param('attempt', PARAM_INT); $thispage = optional_param('thispage', 0, PARAM_INT); $cmid = optional_param('cmid', null, PARAM_INT); $transaction = $DB->start_delegated_transaction(); $attemptobj = quiz_create_attempt_handling_errors($attemptid, $cmid); // Check login. require_login($attemptobj->get_course(), false, $attemptobj->get_cm()); // Check that this attempt belongs to this user. if ($attemptobj->get_userid() != $USER->id) { throw new moodle_exception('notyourattempt', 'quiz', $attemptobj->view_url()); } // Check capabilities. if (!$attemptobj->is_preview_user()) { $attemptobj->require_capability('mod/quiz:attempt'); } // If the attempt is already closed, send them to the review page. if ($attemptobj->is_finished()) { throw new moodle_exception('attemptalreadyclosed', 'quiz', $attemptobj->review_url()); } $attemptobj->process_auto_save($timenow); $transaction->allow_commit(); // Calculate time remaining. $timeleft = $attemptobj->get_time_left_display($timenow); // Build response, only returning timeleft if quiz in-progress // has a time limit. $r = new stdClass(); $r->status = "OK"; if ($timeleft !== false) { $r->timeleft = $timeleft; } echo json_encode($r); PK9B\ׂ overridedelete.phpnu[. /** * This page handles deleting quiz overrides * * @package mod_quiz * @copyright 2010 Matt Petro * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ use mod_quiz\form\edit_override_form; use mod_quiz\quiz_settings; require_once(__DIR__ . '/../../config.php'); require_once($CFG->dirroot.'/mod/quiz/lib.php'); require_once($CFG->dirroot.'/mod/quiz/locallib.php'); $overrideid = required_param('id', PARAM_INT); $confirm = optional_param('confirm', false, PARAM_BOOL); $override = $DB->get_record('quiz_overrides', ['id' => $overrideid], '*', MUST_EXIST); $quizobj = quiz_settings::create($override->quiz); $quiz = $quizobj->get_quiz(); $cm = $quizobj->get_cm(); $course = $quizobj->get_course(); $context = $quizobj->get_context(); $manager = $quizobj->get_override_manager(); require_login($course, false, $cm); // Check the user has the required capabilities to modify an override. $manager->require_manage_capability(); if (!$manager->can_view_override($override, $course, $cm)) { throw new \moodle_exception('invalidoverrideid', 'quiz'); } $url = new moodle_url('/mod/quiz/overridedelete.php', ['id' => $override->id]); $confirmurl = new moodle_url($url, ['id' => $override->id, 'confirm' => 1]); $cancelurl = new moodle_url('/mod/quiz/overrides.php', ['cmid' => $cm->id]); if (!empty($override->userid)) { $cancelurl->param('mode', 'user'); } // If confirm is set (PARAM_BOOL) then we have confirmation of intention to delete. if ($confirm) { require_sesskey(); $manager->delete_overrides(overrides: [$override]); redirect($cancelurl); } // Prepare the page to show the confirmation form. $stroverride = get_string('override', 'quiz'); $title = get_string('deletecheck', null, $stroverride); $PAGE->set_url($url); $PAGE->set_pagelayout('admin'); $PAGE->add_body_class('limitedwidth'); $PAGE->navbar->add($title); $PAGE->set_title($title); $PAGE->set_heading($course->fullname); $PAGE->activityheader->set_attrs([ "title" => format_string($quiz->name, true, ['context' => $context]), "description" => "", "hidecompletion" => true ]); echo $OUTPUT->header(); if ($override->groupid) { $group = $DB->get_record('groups', ['id' => $override->groupid], 'id, name'); $confirmstr = get_string("overridedeletegroupsure", "quiz", format_string($group->name, true, ['context' => $context])); } else { $user = $DB->get_record('user', ['id' => $override->userid]); profile_load_custom_fields($user); $confirmstr = get_string('overridedeleteusersure', 'quiz', edit_override_form::display_user_name($user, \core_user\fields::get_identity_fields($context))); } echo $OUTPUT->confirm($confirmstr, $confirmurl, $cancelurl); echo $OUTPUT->footer(); PK9B\cЗwCjCj styles.cssnu[/** Bits that can appear on any page. */ .path-mod-quiz .statedetails { display: block; font-size: 0.7em; } /** Attempt and review pages **/ #page-mod-quiz-attempt #page .controls, #page-mod-quiz-summary #page .controls, #page-mod-quiz-review #page .controls { text-align: center; margin: 8px auto; } #page-mod-quiz-attempt .submitbtns, #page-mod-quiz-review .submitbtns { display: flex; } #page-mod-quiz-attempt .submitbtns .mod_quiz-next-nav, #page-mod-quiz-review .submitbtns .mod_quiz-next-nav { margin-left: auto; } .path-mod-quiz .mod_quiz-redo_question_button { margin: 0; } .path-mod-quiz input[type="submit"].mod_quiz-redo_question_button { padding: 2px 0.8em; font-size: 1em; } #page-mod-quiz-attempt .mod_quiz-blocked_question_warning .que .formulation, #page-mod-quiz-review .mod_quiz-blocked_question_warning .que .formulation { background: #eee; border: 1px solid #dcdcdc; } #page-mod-quiz-attempt #connection-ok, #page-mod-quiz-attempt #connection-error { position: fixed; top: 0; width: 80%; left: 10%; color: #555; border-radius: 0 0 10px 10px; box-shadow: 5px 5px 20px 0 #666; padding: 1em 1em 0; z-index: 10000; } #page-mod-quiz-attempt #connection-error { background-color: #fcc; } #page-mod-quiz-attempt #connection-ok { background-color: #cfb; width: 60%; left: 20%; } /** Mod quiz attempt **/ .generalbox#passwordbox { /* Should probably match .generalbox#intro above */ width: 70%; margin-left: auto; margin-right: auto; } #passwordform { margin: 1em 0; } /* Question navigation block. */ #quiznojswarning { color: red; } #quiznojswarning { font-size: 0.7em; line-height: 1.1; } .jsenabled #quiznojswarning { display: none; } .path-mod-quiz #user-picture { margin: 0.5em 0; } .path-mod-quiz #user-picture img { width: auto; height: auto; vertical-align: bottom; } .path-mod-quiz #mod_quiz_navblock h3.mod_quiz-section-heading { padding: 0.7em 0 0; margin: 0; clear: both; } .path-mod-quiz #mod_quiz_navblock h3.mod_quiz-section-heading:first-child { padding-top: 0; } .path-mod-quiz .qnbutton { display: block; position: relative; float: left; width: 1.5em; height: 1.5em; overflow: hidden; margin: 0.3em 0.3em 0.3em 0; padding: 0; border: 1px solid #bbb; background: #ddd; text-align: center; line-height: 1.5em; font-weight: bold; text-decoration: none; } .path-mod-quiz .qnbutton:visited:hover, .path-mod-quiz .qnbutton:link:hover { text-decoration: underline; } .path-mod-quiz .qnbutton .trafficlight, .path-mod-quiz .qnbutton .thispageholder { display: block; position: absolute; top: 0; bottom: 0; left: 0; right: 0; } .path-mod-quiz .qnbutton.thispage { border-color: #666; } .path-mod-quiz .qnbutton.thispage .thispageholder { border: 1px solid #666; } .path-mod-quiz .qnbutton.flagged .trafficlight { background: url([[pix:quiz|navflagged]]) no-repeat top right; } .qnbutton.flagged .thispageholder { background: transparent url([[pix:quiz|flag-on]]) 15px 0 no-repeat; } .qnbutton.invalidanswer .trafficlight { background-image: url([[pix:quiz|warningtriangle]]) !important; /* stylelint-disable-line declaration-no-important */ } .path-mod-quiz .qnbutton.blocked, .path-mod-quiz .qnbutton.notyetanswered, .path-mod-quiz .qnbutton.requiresgrading, .path-mod-quiz .qnbutton.invalidanswer { background-color: white; } .path-mod-quiz .qnbutton.correct { background-color: #cfc; } .path-mod-quiz .qnbutton.correct .trafficlight { border-bottom: 3px solid #080; } .path-mod-quiz .qnbutton.partiallycorrect { background-color: #ffa; } .path-mod-quiz .qnbutton.notanswered, .path-mod-quiz .qnbutton.incorrect { background-color: #fcc; } .path-mod-quiz .qnbutton.blocked { color: #999; } .path-mod-quiz .qnbutton.notanswered .trafficlight, .path-mod-quiz .qnbutton.incorrect .trafficlight { border-top: 3px solid #800; } .path-mod-quiz .othernav { clear: both; margin: 0.5em 0; } .path-mod-quiz .autosave_info { font-size: small; } .path-mod-quiz .othernav a, .path-mod-quiz .othernav input { display: block; margin: 0.5em 0; } #mod_quiz_navblock.nav-disabled > * { opacity: .65; pointer-events: none; cursor: not-allowed; } /** mod quiz mod **/ #page-mod-quiz-mod #id_reviewoptionshdr .fitem { width: 23%; margin-left: 10px; } #page-mod-quiz-mod #id_reviewoptionshdr fieldset.fgroup { width: 100%; text-align: left; margin-left: 0; } #page-mod-quiz-mod #id_reviewoptionshdr .fitem { float: left; width: 23%; clear: none; } #page-mod-quiz-mod #id_reviewoptionshdr .fitemtitle { width: 100%; font-weight: bold; text-align: left; height: 2.5em; margin-left: 0; } #page-mod-quiz-mod #id_reviewoptionshdr fieldset.fgroup { clear: left; margin: 0 0 1em; } #page-mod-quiz-mod #id_reviewoptionshdr fieldset.fgroup > span { float: left; clear: left; line-height: 1.7; } #page-mod-quiz-mod #id_reviewoptionshdr fieldset.fgroup span label { margin-left: 0.4em; } /** Mod quiz view **/ #page-mod-quiz-view .quizinfo, #page-mod-quiz-view #page .quizgradefeedback { text-align: center; } #page-mod-quiz-view .generalbox#feedback .overriddennotice { text-align: center; font-size: 0.7em; } .quizstartbuttondiv.quizsecuremoderequired input, .quizstartbuttondiv.quizsecuremoderequired button { display: none; } .jsenabled .quizstartbuttondiv.quizsecuremoderequired input, .jsenabled .quizstartbuttondiv.quizsecuremoderequired button { display: inline; } .quizattempt #mod_quiz_preflight_form { display: none; } #mod_quiz_preflight_form .femptylabel .fitemtitle { display: none; } .moodle-dialogue-base .moodle-dialogue.mod_quiz_preflight_popup { width: 600px; } .moodle-dialogue-base .moodle-dialogue.mod_quiz_preflight_popup .moodle-dialogue-wrap { overflow: hidden; } .moodle-dialogue-base .moodle-dialogue.mod_quiz_preflight_popup .moodle-dialogue-bd { padding: 1rem; } /* Standard Moodle rule that needs to be more specific here. */ .moodle-dialogue-bd #mod_quiz_preflight_form fieldset.hidden { display: inherit; visibility: inherit; } body.path-mod-quiz .gradedattempt, body.path-mod-quiz table tbody tr.gradedattempt > td, body.path-mod-quiz table tbody tr.gradedattempt > td.sticky-column { border-color: #bce8f1; background-color: #d9edf7; } body.path-mod-quiz table tbody tr > td.sticky-column, body.path-mod-quiz table thead tr > th.sticky-column, body.path-mod-quiz table tbody tr:not(.gradedattempt):hover td.sticky-column { background-color: #fff; } /** Mod quiz summary **/ #page-mod-quiz-summary #content { text-align: center; } #page-mod-quiz-summary #quiz-timer { text-align: center; margin-top: 1em; } #page-mod-quiz-summary .submitbtns { margin-top: 1.5em; } @media print { .quiz-secure-window * { display: none; } } /** Mod quiz review **/ table.quizreviewsummary { width: 100%; } table.quizreviewsummary th.cell { padding: 1px 0.5em 1px 1em; font-weight: bold; text-align: right; width: 10em; background: #f0f0f0; } table.quizreviewsummary td.cell { padding: 1px 1em 1px 0.5em; text-align: left; background: #fafafa; } /** Mod quiz make comment or override grade popup. **/ #page-mod-quiz-comment .mform { width: 100%; } #page-mod-quiz-comment .mform fieldset { margin: 0; border-bottom: none; } #page-mod-quiz-comment .que { margin: 0; } /** Mod quiz report **/ #page-mod-quiz-report h2.main { clear: both; } #page-mod-quiz-report .controls { text-align: center; } #page-mod-quiz-report .dubious { background-color: #fcc; } #page-mod-quiz-report .highlight { border: 1px solid #bce8f1; background-color: #d9edf7; } #page-mod-quiz-report .negcovar { border: medium solid pink; } #page-mod-quiz-report .gradetheselink { font-size: 0.8em; } #page-mod-quiz-report .mform fieldset.fgroup span label { margin-right: 14px; } #page-mod-quiz-report table th { white-space: normal; } #page-mod-quiz-report table#attempts td, #page-mod-quiz-report table.quizresponseanalysis td { word-wrap: break-word; max-width: 20em; } #page-mod-quiz-report table.titlesleft td.c0 { font-weight: bold; } #page-mod-quiz-report table .numcol { text-align: center; vertical-align: middle; } #page-mod-quiz-report table#attempts { clear: both; margin: 0.2em auto; } #page-mod-quiz-report table#attempts .header, #page-mod-quiz-report table#attempts .cell { padding: 4px; } #page-mod-quiz-report table#attempts .header .commands { display: inline; } #page-mod-quiz-report table#attempts .picture { width: 40px; } #page-mod-quiz-report table#attempts td { border-left-width: 1px; border-right-width: 1px; border-left-style: solid; border-right-style: solid; vertical-align: middle; } #page-mod-quiz-report table#attempts .header { text-align: left; } #page-mod-quiz-report table#attempts .picture { text-align: center; } #page-mod-quiz-report table#attempts.grades span.que, #page-mod-quiz-report table#attempts span.avgcell { white-space: nowrap; } #page-mod-quiz-report table#attempts span.que .requiresgrading { white-space: normal; } #page-mod-quiz-report table#attempts .questionflag { vertical-align: text-bottom; padding-left: 6px; } #page-mod-quiz-report .graph.flexible-wrap { text-align: center; overflow: auto; } #page-mod-quiz-report #cachingnotice { margin-bottom: 1em; padding: 0.2em; } #page-mod-quiz-report #cachingnotice .singlebutton { margin: 0.5em 0 0; } #page-mod-quiz-report .bold .reviewlink { font-weight: normal; } #page-mod-quiz-report tr.lastrowforattempt { border-bottom: lightgrey solid 0.2em; } #page-mod-quiz-report tr.quiz_statistics-summaryrow td.cell { padding-top: 1px; padding-bottom: 1px; border-top: none; } /** Mod quiz edit **/ #page-mod-quiz-edit .statusdisplay { background-color: #ffc; clear: both; margin: 0.3em 0; padding: 1px 10px; } #page-mod-quiz-edit .statusdisplay p { margin: 4px 0; } #page-mod-quiz-edit .mod_quiz-edit-top-controls { position: relative; } #page-mod-quiz-edit .mod_quiz-edit-action-buttons { display: block; min-height: 2.85em; } #page-mod-quiz-edit .maxgrade label { display: inline; } #page-mod-quiz-edit .maxgrade input[type="submit"] { margin: 0; } #page-mod-quiz-edit li.activity > div, #page-mod-quiz-edit li.pagenumber { position: relative; } #page-mod-quiz-edit ul.section li.pagenumber:first-child .add-menu-outer .menu > :last-child, #page-mod-quiz-edit .last-add-menu .add-menu-outer .menu > :last-child { display: none; } #page-mod-quiz-edit .last-add-menu { position: relative; height: 1.5em; margin: 0 20px; } #page-mod-quiz-edit .add-menu-outer { position: absolute; right: 0; } #page-mod-quiz-edit .slotnumber { text-align: center; margin: 0.1em 0.5em; display: inline-block; } #page-mod-quiz-edit li.section .slotnumber.shuffled { display: none; } #page-mod-quiz-edit li.section.shuffled .slotnumber.unshuffled { display: none; } #page-mod-quiz-edit li.section.shuffled .slotnumber.shuffled { display: inline-block; } #page-mod-quiz-edit .slotnumber .inplaceeditable { white-space: nowrap; } #page-mod-quiz-edit .section-heading { margin-left: 20px; margin-bottom: 0; height: 40px; } #page-mod-quiz-edit .section-heading .instancesectioncontainer { display: inline; } #page-mod-quiz-edit .section-heading .instancesectioncontainer h3 { display: inline; color: #555; } #page-mod-quiz-edit .section-heading .editing_section, #page-mod-quiz-edit .section-heading .editing_delete { margin-left: 10px; } #page-mod-quiz-edit .section-heading .sectioninstance { position: relative; } #page-mod-quiz-edit .section-heading .instancesection { white-space: nowrap; max-width: 72%; display: inline-block; text-overflow: ellipsis; overflow: hidden; vertical-align: bottom; } #page-mod-quiz-edit .section-heading form { display: inline; position: relative; top: 3px; left: -7px; } #page-mod-quiz-edit .section-heading form input { font-size: 24px; font-weight: bold; width: 50%; } #page-mod-quiz-edit .section-heading .instanceshufflequestions { float: right; margin: 0.3em 20px 0 0; } .instanceshufflequestions [type="checkbox"] { vertical-align: middle; margin-right: .5rem; } #page-mod-quiz-edit ul.section { margin: 0; padding: 0 20px; } #page-mod-quiz-edit ul.slots { margin: 0; padding: 0; } #page-mod-quiz-edit ul.slots li.section { border: 0; } #page-mod-quiz-edit ul.slots li.section .content { background-color: #fafafa; padding: 1px 0; } #page-mod-quiz-edit ul.slots li.section { list-style: none; margin: 0; padding: 0; } #page-mod-quiz-edit ul.slots li.section li.activity { background: #e6e6e6; margin: 3px 0; padding: 0.2em; position: relative; } #page-mod-quiz-edit ul.slots li.section li.activity.qtype_missingtype { background-color: #f0c5c1; } #page-mod-quiz-edit ul.slots li.section li.activity.qtype_missingtype .actions { background-color: inherit; } #page-mod-quiz-edit ul.slots li.section li.activity.page { background: transparent; } #page-mod-quiz-edit ul.slots li.section li.activity.page h4 { display: inline; font-weight: normal; font-size: 1em; } #page-mod-quiz-edit ul.slots li.section li.activity .instancemaxmarkcontainer { background: white; padding: 0.5em 0 0.5em 0.1em; margin: 2px; } #page-mod-quiz-edit ul.slots li.section li.activity .instancemaxmarkcontainer .editicon { width: 13px; } #page-mod-quiz-edit ul.slots li.section li.activity .instancemaxmarkcontainer.infoitem { background: transparent; } #page-mod-quiz-edit ul.slots li.section li.activity .instancemaxmarkcontainer form { display: inline; } #page-mod-quiz-edit ul.slots li.section li.activity .instancemaxmark { display: inline-block; text-align: right; } #page-mod-quiz-edit ul.slots li.section li.activity .page_split_join_wrapper { position: absolute; left: -20px; bottom: -11px; } #page-mod-quiz-edit ul.slots li.section li.activity .instancemaxmark.decimalplaces_0 { min-width: 1.3em; } #page-mod-quiz-edit ul.slots li.section li.activity .instancemaxmark.decimalplaces_1 { min-width: 2em; } #page-mod-quiz-edit ul.slots li.section li.activity .instancemaxmark.decimalplaces_2 { min-width: 2.6em; } #page-mod-quiz-edit ul.slots li.section li.activity .instancemaxmark.decimalplaces_3 { min-width: 3.2em; } #page-mod-quiz-edit ul.slots li.section li.activity .instancemaxmark.decimalplaces_4 { min-width: 3.7em; } #page-mod-quiz-edit ul.slots li.section li.activity .instancemaxmark.decimalplaces_5 { min-width: 4.3em; } #page-mod-quiz-edit ul.slots li.section li.activity .instancemaxmark.decimalplaces_6 { min-width: 4.8em; } #page-mod-quiz-edit ul.slots li.section li.activity .instancemaxmark.decimalplaces_7 { min-width: 5.45em; } #page-mod-quiz-edit ul.slots li.section li.activity .edit_icon, #page-mod-quiz-edit ul.slots li.section li.activity a.preview, #page-mod-quiz-edit ul.slots li.section li.activity .editing_delete, #page-mod-quiz-edit ul.slots li.section li.activity .editing_maxmark { margin: 0 2px; } #page-mod-quiz-edit ul.slots li.section.only-has-one-slot li.activity .editing_move, #page-mod-quiz-edit ul.slots li.section.only-has-one-slot li.activity .editing_delete { visibility: hidden; } #page-mod-quiz-edit ul.slots.only-one-section li.section.only-has-one-slot li.activity .editing_delete { visibility: visible; } #page-mod-quiz-edit ul.slots li.section li.activity .question_dependency_wrapper { position: absolute; top: -1em; right: -20px; } #page-mod-quiz-edit ul.slots li.section li.activity .question_dependency_wrapper.question_dependency_cannot_depend { display: none; } #page-mod-quiz-edit ul.slots li.section li.activity .activityinstance { display: flex; flex-wrap: wrap; flex: 1 1 auto; min-height: 1.7em; padding-right: 2px; } #page-mod-quiz-edit ul.slots li.section li.activity .mod-indent-outer { display: flex; padding-left: 22px; } #page-mod-quiz-edit ul.slots .activityinstance form { display: inline; } #page-mod-quiz-edit span.editinstructions { right: 0; } #page-mod-quiz-edit ul.slots .activityinstance span.instancename { overflow-x: hidden; overflow-y: hidden; text-overflow: ellipsis; } #page-mod-quiz-edit ul.slots .activityinstance span.instancename img { margin: 0 0.2em; } #page-mod-quiz-edit .question-bank-table .questionname, #page-mod-quiz-edit ul.slots li.activity div.activityinstance .questionname { font-weight: bold; color: #555; overflow: hidden; text-overflow: ellipsis; } #page-mod-quiz-edit ul.slots li.activity div.activityinstance .questiontext { color: #555; } #page-mod-quiz-edit .sharedbank .badge { white-space: initial; } #page-mod-quiz-edit .section .activity .editing_move { position: absolute; left: 0; top: 0; } #page-mod-quiz-edit ul.slots li.activity div.activityinstance .mod_quiz_random_qbank_link { font-size: 0.8em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; margin-left: 0.25rem; } #page-mod-quiz-edit ul.slots .activityinstance img.activityicon { float: inherit; margin: .2em 0 0; padding: 0; } #page-mod-quiz-edit .section .activity .actions { position: inherit; white-space: nowrap; background: #e6e6e6; padding: 0.1em 0; display: flex; align-items: baseline; } #page-mod-quiz-edit .mod_quiz_edit_forms { display: none; } #page-mod-quiz-edit .question-bank-table { width: 100%; } #page-mod-quiz-edit .question-bank-table .header { text-align: center; padding: 0 2px; border: 0 none; vertical-align: top; width: auto; } #page-mod-quiz-edit .question-bank-table .header.checkbox { vertical-align: bottom; text-align: center; } #page-mod-quiz-edit .question-bank-table .header.qtype .sorters { white-space: nowrap; } #page-mod-quiz-edit .question-bank-table th .sorters { font-weight: normal; font-size: 0.8em; } #page-mod-quiz-edit .question-bank-table td.modifiername, #page-mod-quiz-edit .question-bank-table td.creatorname, #page-mod-quiz-edit .question-bank-table td.questionlastused { line-height: 1em; } #page-mod-quiz-edit .question-bank-table td.modifiername span.date, #page-mod-quiz-edit .question-bank-table td.creatorname span.date { font-weight: normal; font-size: 0.8em; } #page-mod-quiz-edit table.question-bank-table { table-layout: fixed; overflow-x: visible; border-collapse: separate; border-spacing: 0; } #page-mod-quiz-edit .question-bank-table .iconcol { width: 16px; text-align: center; padding: 0; } #page-mod-quiz-edit .question-bank-table .iconcol .icon { width: 16px; } #page-mod-quiz-edit .question-bank-table .checkbox { width: 32px; text-align: center; padding: 0; } #page-mod-quiz-edit .question-bank-table .editmenu { width: 5em; } #page-mod-quiz-edit .question-bank-table .qtype { text-align: center; } #page-mod-quiz-edit .question-bank-table .qtype { width: 28px; padding: 0; } #page-mod-quiz-edit .question-bank-table .questionnametext { padding: 0 0.2em; width: 100% !important; /* stylelint-disable-line declaration-no-important */ } #page-mod-quiz-edit .question-bank-table .questiontext { position: relative; zoom: 1; padding: 0.3em; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; } #page-mod-quiz-edit .question-bank-table .questionname { white-space: nowrap; overflow: hidden; zoom: 1; position: relative; } #page-mod-quiz-edit .question-bank-table .questiontext p { margin: 0; } #page-mod-quiz-edit table.question-bank-table td, #page-mod-quiz-edit table.question-bank-table th { overflow: hidden; white-space: nowrap; width: 1.2em !important; /* stylelint-disable-line declaration-no-important */ } #page-mod-quiz-edit table.question-bank-table td.questionnametext > div { overflow: hidden; text-overflow: ellipsis; } .mod_quiz_qbank_dialogue { width: 80%; min-height: 200px; } .mod_quiz_qbank_dialogue.moodle-dialogue-fullscreen { width: 100%; } .mod_quiz_qbank_dialogue .questionbankloading { position: absolute; top: 30px; bottom: 0; left: 0; right: 0; background: #fff; text-align: center; opacity: 0.5; padding-top: 50px; } .mod_quiz_qbank_dialogue #advancedsearch label { font-size: 100%; } .quizquestionlistcontrols { text-align: center; } .categoryinfo { padding: 0.3em; } .path-mod-quiz .gradingdetails { font-size: small; } #page-mod-quiz-edit div#repaginatedialog .mform { margin-left: auto; margin-right: auto; } #page-mod-quiz-edit div.container div.generalbox { position: relative; display: block; border: 0 none; margin: 0; padding: 0; } #page-mod-quiz-edit .paging { margin-top: 0; margin-bottom: 0; padding: 0.1em 0.3em; display: block; background-color: #ddd; } #page-mod-quiz-edit #page-footer { clear: both; padding-top: 1em; } #page-mod-quiz-edit .categoryinfofield { font-style: italic; } #page-mod-quiz-edit .categorynamefield { font-weight: bold; } #page-mod-quiz-edit .questionsortoptions { background-color: #ddd; } #page-mod-quiz-edit div.questionbank .categorysortopotionscontainer { padding-top: 0.5em; margin-top: 0.3em; } #page-mod-quiz-edit div.questionbank .categoryquestionscontainer, .questionbank .categorysortopotionscontainer, .questionbank .categorypagingbarcontainer, .questionbank .categoryselectallcontainer { background-color: #fff; } /* Bulk edit actions */ #page-mod-quiz-edit .btn-group.selectmultiplecommand, #page-mod-quiz-edit .selectmultiplecommandbuttons, #page-mod-quiz-edit .select-multiple-checkbox { display: none; } #page-mod-quiz-edit.select-multiple .selectmultiplecommand, #page-mod-quiz-edit.select-multiple .selectmultiplecommandbuttons, #page-mod-quiz-edit.select-multiple .select-multiple-checkbox { display: inline-block; } #page-mod-quiz-edit.select-multiple input.select-multiple-checkbox[type="checkbox"] { display: inline; } #page-mod-quiz-edit.select-multiple .mod-quiz-edit-content .section .activity .editing_move, #page-mod-quiz-edit.select-multiple .mod-quiz-edit-content .section .activity .commands { display: none; } #page-mod-quiz-edit.select-multiple .mod-quiz-edit-content .section .page_split_join_wrapper { display: none; } #page-mod-quiz-edit.select-multiple .mod-quiz-edit-content .section .activity .actions .editing_delete, #page-mod-quiz-edit.select-multiple .mod-quiz-edit-content .section .activity .actions .editing_maxmark { display: none; } #page-mod-quiz-edit.select-multiple#page-mod-quiz-edit .maxgrade, #page-mod-quiz-edit.select-multiple .mod-quiz-edit-content .last-add-menu { display: none; } #page-mod-quiz-edit.select-multiple .mod-quiz-edit-content .section-heading a, #page-mod-quiz-edit.select-multiple .mod-quiz-edit-content .section-heading form, #page-mod-quiz-edit.select-multiple .mod-quiz-edit-content .section-heading .instanceshufflequestions { display: none; } #page-mod-quiz-edit.select-multiple .mod-quiz-edit-content .edit-toolbar .mb-1 { display: none; } #page-mod-quiz-edit.select-multiple#page-mod-quiz-edit ul.slots li.section li.activity .mod-indent-outer { padding-left: 3px; } #page-mod-quiz-edit .section .summary .iconsmall, #page-mod-quiz-edit .section .activity .iconsmall { float: left; } /* Base theme needs extra support. */ #page-mod-quiz-edit ul.slots li.section ul.section { list-style: none; } #page-mod-quiz-edit .inplaceeditable { box-sizing: border-box; background: #fdfdfe; display: inline-block; margin: 2px; padding: 0.5em 0 0.5em 0.1em; } #page-mod-quiz-edit .inplaceeditable.inplaceeditingon input { width: 64px; vertical-align: middle; } #page-mod-quiz-edit .inplaceeditable-text { color: #000; text-align: right; } #page-mod-quiz-editgrading .inplaceeditable .quickeditlink { color: var(--bs-primary); } #page-mod-quiz-edit .inplaceeditable .quickeditlink { color: var(--bs-primary); text-decoration: none; background-color: transparent; padding-left: 1px; margin: 0; } #page-mod-quiz-edit .quickeditlink .aalink { color: var(--bs-primary); text-align: right; width: 10px; margin: 2px; } #page-mod-quiz-edit .quickedition { color: var(--bs-primary); text-align: right; width: 10px; margin: 2px; } #page-mod-quiz-edit .quickedition .visibleifjs { text-align: right; } #page-mod-quiz-edit .quickeditlink { text-align: right; } @media (max-width: 576px) { #page-mod-quiz-edit ul.slots li.section li.activity .mod-indent { display: none; } #page-mod-quiz-edit ul.slots li.section li.activity .mod-indent-outer, #page-mod-quiz-edit li.section .slotnumber.unshuffled, #page-mod-quiz-edit li.section.shuffled .slotnumber.shuffled, #page-mod-quiz-edit ul.slots li.section li.activity .activityinstance, #page-mod-quiz-edit ul.slots li.section li.activity .actions { display: block; } } /** Print formatting for attempt and review pages **/ @media print { #page-mod-quiz-attempt header.navbar, #page-mod-quiz-review header.navbar { display: none; } #page-mod-quiz-attempt #dock, #page-mod-quiz-review #dock { display: none; } #page-mod-quiz-attempt #page #page-header h1, #page-mod-quiz-review #page #page-header h1 { display: none; } #page-mod-quiz-attempt #region-main, #page-mod-quiz-review #region-main { width: 100%; } #page-mod-quiz-attempt #block-region-side-pre, #page-mod-quiz-attempt #block-region-side-post, #page-mod-quiz-review #block-region-side-pre, #page-mod-quiz-review #block-region-side-post { display: none; } #page-mod-quiz-attempt #page-footer, #page-mod-quiz-review #page-footer { display: none; } #page-mod-quiz-attempt .editquestion, #page-mod-quiz-review .editquestion, #page-mod-quiz-attempt .questionflag, #page-mod-quiz-review .questionflag { display: none; } #page-mod-quiz-attempt .submitbtns, #page-mod-quiz-review .submitbtns { display: none; } #page-mod-quiz-review .que .commentlink { display: none; } #page-mod-quiz-attempt .que, #page-mod-quiz-review .que { page-break-inside: avoid; } } PK9B\ renderer.phpnu[. /** * File only retained to prevent fatal errors in code that tries to require/include this. * * @todo MDL-76612 delete this file as part of Moodle 4.6 development. * @deprecated This file is no longer required in Moodle 4.2+. */ // Normally I would debug-output // 'This file is no longer required in Moodle 4.2+. Please do not include/require it.' // but this file gets automatically included by the renderer factories in a way that // is hard to prevent, so not doing that here. PK9B\&?2?2db/services.phpnu[. /** * Quiz external functions and service definitions. * * @package mod_quiz * @category external * @copyright 2016 Juan Leyva * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @since Moodle 3.1 */ defined('MOODLE_INTERNAL') || die; $functions = [ 'mod_quiz_get_quizzes_by_courses' => [ 'classname' => 'mod_quiz_external', 'methodname' => 'get_quizzes_by_courses', 'description' => 'Returns a list of quizzes in a provided list of courses, if no list is provided all quizzes that the user can view will be returned.', 'type' => 'read', 'capabilities' => 'mod/quiz:view', 'services' => [MOODLE_OFFICIAL_MOBILE_SERVICE] ], 'mod_quiz_view_quiz' => [ 'classname' => 'mod_quiz_external', 'methodname' => 'view_quiz', 'description' => 'Trigger the course module viewed event and update the module completion status.', 'type' => 'write', 'capabilities' => 'mod/quiz:view', 'services' => [MOODLE_OFFICIAL_MOBILE_SERVICE] ], 'mod_quiz_get_user_attempts' => [ 'classname' => 'mod_quiz_external', 'methodname' => 'get_user_attempts', 'description' => 'Return a list of attempts for the given quiz and user. ' . '(Deprecated in favour of mod_quiz_get_user_quiz_attempts).', 'type' => 'read', 'capabilities' => 'mod/quiz:view', 'services' => [MOODLE_OFFICIAL_MOBILE_SERVICE] ], 'mod_quiz_get_user_quiz_attempts' => [ 'classname' => 'mod_quiz_external', 'methodname' => 'get_user_quiz_attempts', 'description' => 'Return a list of attempts for the given quiz and user.', 'type' => 'read', 'capabilities' => 'mod/quiz:view', 'services' => [MOODLE_OFFICIAL_MOBILE_SERVICE], ], 'mod_quiz_get_user_best_grade' => [ 'classname' => 'mod_quiz_external', 'methodname' => 'get_user_best_grade', 'description' => 'Get the best current grade for the given user on a quiz.', 'type' => 'read', 'capabilities' => 'mod/quiz:view', 'services' => [MOODLE_OFFICIAL_MOBILE_SERVICE] ], 'mod_quiz_get_combined_review_options' => [ 'classname' => 'mod_quiz_external', 'methodname' => 'get_combined_review_options', 'description' => 'Combines the review options from a number of different quiz attempts.', 'type' => 'read', 'capabilities' => 'mod/quiz:view', 'services' => [MOODLE_OFFICIAL_MOBILE_SERVICE] ], 'mod_quiz_start_attempt' => [ 'classname' => 'mod_quiz_external', 'methodname' => 'start_attempt', 'description' => 'Starts a new attempt at a quiz.', 'type' => 'write', 'capabilities' => 'mod/quiz:attempt', 'services' => [MOODLE_OFFICIAL_MOBILE_SERVICE] ], 'mod_quiz_get_attempt_data' => [ 'classname' => 'mod_quiz_external', 'methodname' => 'get_attempt_data', 'description' => 'Returns information for the given attempt page for a quiz attempt in progress.', 'type' => 'read', 'capabilities' => 'mod/quiz:attempt', 'services' => [MOODLE_OFFICIAL_MOBILE_SERVICE] ], 'mod_quiz_get_attempt_summary' => [ 'classname' => 'mod_quiz_external', 'methodname' => 'get_attempt_summary', 'description' => 'Returns a summary of a quiz attempt before it is submitted.', 'type' => 'read', 'capabilities' => 'mod/quiz:attempt', 'services' => [MOODLE_OFFICIAL_MOBILE_SERVICE] ], 'mod_quiz_save_attempt' => [ 'classname' => 'mod_quiz_external', 'methodname' => 'save_attempt', 'description' => 'Processes save requests during the quiz. This function is intended for the quiz auto-save feature.', 'type' => 'write', 'capabilities' => 'mod/quiz:attempt', 'services' => [MOODLE_OFFICIAL_MOBILE_SERVICE] ], 'mod_quiz_process_attempt' => [ 'classname' => 'mod_quiz_external', 'methodname' => 'process_attempt', 'description' => 'Process responses during an attempt at a quiz and also deals with attempts finishing.', 'type' => 'write', 'capabilities' => 'mod/quiz:attempt', 'services' => [MOODLE_OFFICIAL_MOBILE_SERVICE] ], 'mod_quiz_get_attempt_review' => [ 'classname' => 'mod_quiz_external', 'methodname' => 'get_attempt_review', 'description' => 'Returns review information for the given finished attempt, can be used by users or teachers.', 'type' => 'read', 'capabilities' => 'mod/quiz:reviewmyattempts', 'services' => [MOODLE_OFFICIAL_MOBILE_SERVICE] ], 'mod_quiz_view_attempt' => [ 'classname' => 'mod_quiz_external', 'methodname' => 'view_attempt', 'description' => 'Trigger the attempt viewed event.', 'type' => 'write', 'capabilities' => 'mod/quiz:attempt', 'services' => [MOODLE_OFFICIAL_MOBILE_SERVICE] ], 'mod_quiz_view_attempt_summary' => [ 'classname' => 'mod_quiz_external', 'methodname' => 'view_attempt_summary', 'description' => 'Trigger the attempt summary viewed event.', 'type' => 'write', 'capabilities' => 'mod/quiz:attempt', 'services' => [MOODLE_OFFICIAL_MOBILE_SERVICE] ], 'mod_quiz_view_attempt_review' => [ 'classname' => 'mod_quiz_external', 'methodname' => 'view_attempt_review', 'description' => 'Trigger the attempt reviewed event.', 'type' => 'write', 'capabilities' => 'mod/quiz:reviewmyattempts', 'services' => [MOODLE_OFFICIAL_MOBILE_SERVICE] ], 'mod_quiz_get_quiz_feedback_for_grade' => [ 'classname' => 'mod_quiz_external', 'methodname' => 'get_quiz_feedback_for_grade', 'description' => 'Get the feedback text that should be show to a student who got the given grade in the given quiz.', 'type' => 'read', 'capabilities' => 'mod/quiz:view', 'services' => [MOODLE_OFFICIAL_MOBILE_SERVICE] ], 'mod_quiz_get_quiz_access_information' => [ 'classname' => 'mod_quiz_external', 'methodname' => 'get_quiz_access_information', 'description' => 'Return access information for a given quiz.', 'type' => 'read', 'capabilities' => 'mod/quiz:view', 'services' => [MOODLE_OFFICIAL_MOBILE_SERVICE] ], 'mod_quiz_get_attempt_access_information' => [ 'classname' => 'mod_quiz_external', 'methodname' => 'get_attempt_access_information', 'description' => 'Return access information for a given attempt in a quiz.', 'type' => 'read', 'capabilities' => 'mod/quiz:view', 'services' => [MOODLE_OFFICIAL_MOBILE_SERVICE] ], 'mod_quiz_get_quiz_required_qtypes' => [ 'classname' => 'mod_quiz_external', 'methodname' => 'get_quiz_required_qtypes', 'description' => 'Return the potential question types that would be required for a given quiz.', 'type' => 'read', 'capabilities' => 'mod/quiz:view', 'services' => [MOODLE_OFFICIAL_MOBILE_SERVICE] ], 'mod_quiz_set_question_version' => [ 'classname' => 'mod_quiz\external\submit_question_version', 'description' => 'Set the version of question that would be required for a given quiz.', 'type' => 'write', 'capabilities' => 'mod/quiz:view', 'ajax' => true, ], 'mod_quiz_reopen_attempt' => [ 'classname' => 'mod_quiz\external\reopen_attempt', 'description' => 'Re-open an attempt that is currently in the never submitted state.', 'type' => 'write', 'capabilities' => 'mod/quiz:reopenattempts', 'ajax' => true, ], 'mod_quiz_get_reopen_attempt_confirmation' => [ 'classname' => 'mod_quiz\external\get_reopen_attempt_confirmation', 'description' => 'Verify it is OK to re-open a given quiz attempt, and if so, return a suitable confirmation message.', 'type' => 'read', 'capabilities' => 'mod/quiz:reopenattempts', 'ajax' => true, ], 'mod_quiz_add_random_questions' => [ 'classname' => 'mod_quiz\external\add_random_questions', 'description' => 'Add a number of random questions to a quiz.', 'type' => 'write', 'capabilities' => 'mod/quiz:manage', 'ajax' => true, ], 'mod_quiz_update_filter_condition' => [ 'classname' => 'mod_quiz\external\update_filter_condition', 'description' => 'Update filter condition for a random question slot.', 'type' => 'write', 'capabilities' => 'mod/quiz:manage', 'ajax' => true, ], 'mod_quiz_save_overrides' => [ 'classname' => 'mod_quiz\external\save_overrides', 'description' => 'Update or insert quiz overrides', 'type' => 'write', 'capabilities' => 'mod/quiz:manageoverrides', 'ajax' => true, ], 'mod_quiz_delete_overrides' => [ 'classname' => 'mod_quiz\external\delete_overrides', 'description' => 'Delete quiz overrides', 'type' => 'write', 'capabilities' => 'mod/quiz:manageoverrides', 'ajax' => true, ], 'mod_quiz_get_overrides' => [ 'classname' => 'mod_quiz\external\get_overrides', 'description' => 'Get quiz overrides', 'type' => 'read', 'capabilities' => 'mod/quiz:manageoverrides', 'ajax' => true, ], 'mod_quiz_create_grade_items' => [ 'classname' => 'mod_quiz\external\create_grade_items', 'description' => 'Create quiz grade items. All grade items must belong to the same quiz.', 'type' => 'write', 'capabilities' => 'mod/quiz:manage', 'ajax' => true, ], 'mod_quiz_delete_grade_items' => [ 'classname' => 'mod_quiz\external\delete_grade_items', 'description' => 'Delete quiz grade items. All grade items must belong to the same quiz.', 'type' => 'write', 'capabilities' => 'mod/quiz:manage', 'ajax' => true, ], 'mod_quiz_update_grade_items' => [ 'classname' => 'mod_quiz\external\update_grade_items', 'description' => 'Update quiz grade items. All grade items must belong to the same quiz.', 'type' => 'write', 'capabilities' => 'mod/quiz:manage', 'ajax' => true, ], 'mod_quiz_update_slots' => [ 'classname' => 'mod_quiz\external\update_slots', 'description' => 'Update the properties of slots in a quiz. All slots must belong to the same quiz.', 'type' => 'write', 'capabilities' => 'mod/quiz:manage', 'ajax' => true, ], 'mod_quiz_get_edit_grading_page_data' => [ 'classname' => 'mod_quiz\external\get_edit_grading_page_data', 'description' => 'Get the data required to re-render the Quiz grading setup page', 'type' => 'read', 'capabilities' => 'mod/quiz:manage', 'ajax' => true, ], 'mod_quiz_create_grade_item_per_section' => [ 'classname' => 'mod_quiz\external\create_grade_item_per_section', 'description' => 'For a quiz with no grade items yet, create a grade item for each section, with the questions in that section assigned.', 'type' => 'write', 'capabilities' => 'mod/quiz:manage', 'ajax' => true, ], ]; PK9B\.{db/subplugins.jsonnu[{ "subplugintypes": { "quiz": "report", "quizaccess": "accessrule" }, "plugintypes": { "quiz": "mod/quiz/report", "quizaccess": "mod/quiz/accessrule" } } PK9B\`Zt  db/log.phpnu[. /** * Definition of log events for the quiz module. * * @package mod_quiz * @category log * @copyright 2010 Petr Skoda (http://skodak.org) * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); $logs = [ ['module' => 'quiz', 'action' => 'add', 'mtable' => 'quiz', 'field' => 'name'], ['module' => 'quiz', 'action' => 'update', 'mtable' => 'quiz', 'field' => 'name'], ['module' => 'quiz', 'action' => 'view', 'mtable' => 'quiz', 'field' => 'name'], ['module' => 'quiz', 'action' => 'report', 'mtable' => 'quiz', 'field' => 'name'], ['module' => 'quiz', 'action' => 'attempt', 'mtable' => 'quiz', 'field' => 'name'], ['module' => 'quiz', 'action' => 'submit', 'mtable' => 'quiz', 'field' => 'name'], ['module' => 'quiz', 'action' => 'review', 'mtable' => 'quiz', 'field' => 'name'], ['module' => 'quiz', 'action' => 'editquestions', 'mtable' => 'quiz', 'field' => 'name'], ['module' => 'quiz', 'action' => 'preview', 'mtable' => 'quiz', 'field' => 'name'], ['module' => 'quiz', 'action' => 'start attempt', 'mtable' => 'quiz', 'field' => 'name'], ['module' => 'quiz', 'action' => 'close attempt', 'mtable' => 'quiz', 'field' => 'name'], ['module' => 'quiz', 'action' => 'continue attempt', 'mtable' => 'quiz', 'field' => 'name'], ['module' => 'quiz', 'action' => 'edit override', 'mtable' => 'quiz', 'field' => 'name'], ['module' => 'quiz', 'action' => 'delete override', 'mtable' => 'quiz', 'field' => 'name'], ['module' => 'quiz', 'action' => 'view summary', 'mtable' => 'quiz', 'field' => 'name'], ]; PK9B\9vhh db/tasks.phpnu[. /** * Definition of Quiz scheduled tasks. * * @package mod_quiz * @category task * @copyright 2017 Michael Hughes * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); $tasks = [ [ 'classname' => 'mod_quiz\task\update_overdue_attempts', 'blocking' => 0, 'minute' => '*', 'hour' => '*', 'day' => '*', 'dayofweek' => '*', 'month' => '*' ], [ 'classname' => 'mod_quiz\task\quiz_notify_attempt_manual_grading_completed', 'blocking' => 0, 'minute' => 'R', 'hour' => '*', 'day' => '*', 'dayofweek' => '*', 'month' => '*' ], [ 'classname' => 'mod_quiz\task\queue_all_quiz_open_notification_tasks', 'blocking' => 0, 'minute' => 'R', 'hour' => '*/2', 'day' => '*', 'month' => '*', 'dayofweek' => '*', ], [ 'classname' => 'mod_quiz\task\precreate_attempts', 'blocking' => 0, 'minute' => 'R', 'hour' => '*', 'day' => '*', 'dayofweek' => '*', 'month' => '*', ], ]; PK9B\tr   db/access.phpnu[. /** * Capability definitions for the quiz module. * * @package mod_quiz * @copyright 2006 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); $capabilities = [ // Ability to see that the quiz exists, and the basic information // about it, for example the start date and time limit. 'mod/quiz:view' => [ 'captype' => 'read', 'contextlevel' => CONTEXT_MODULE, 'archetypes' => [ 'guest' => CAP_ALLOW, 'student' => CAP_ALLOW, 'teacher' => CAP_ALLOW, 'editingteacher' => CAP_ALLOW, 'manager' => CAP_ALLOW ] ], // Ability to add a new quiz to the course. 'mod/quiz:addinstance' => [ 'riskbitmask' => RISK_XSS, 'captype' => 'write', 'contextlevel' => CONTEXT_COURSE, 'archetypes' => [ 'editingteacher' => CAP_ALLOW, 'manager' => CAP_ALLOW ], 'clonepermissionsfrom' => 'moodle/course:manageactivities' ], // Ability to do the quiz as a 'student'. 'mod/quiz:attempt' => [ 'riskbitmask' => RISK_SPAM, 'captype' => 'write', 'contextlevel' => CONTEXT_MODULE, 'archetypes' => [ 'student' => CAP_ALLOW ] ], // Ability for a 'Student' to review their previous attempts. Review by // 'Teachers' is controlled by mod/quiz:viewreports. 'mod/quiz:reviewmyattempts' => [ 'captype' => 'read', 'contextlevel' => CONTEXT_MODULE, 'archetypes' => [ 'student' => CAP_ALLOW ], 'clonepermissionsfrom' => 'moodle/quiz:attempt' ], // Edit the quiz settings, add and remove questions. 'mod/quiz:manage' => [ 'riskbitmask' => RISK_SPAM, 'captype' => 'write', 'contextlevel' => CONTEXT_MODULE, 'archetypes' => [ 'editingteacher' => CAP_ALLOW, 'manager' => CAP_ALLOW ] ], // Edit the quiz overrides. 'mod/quiz:manageoverrides' => [ 'captype' => 'write', 'contextlevel' => CONTEXT_MODULE, 'archetypes' => [ 'editingteacher' => CAP_ALLOW, 'manager' => CAP_ALLOW ] ], // View the quiz overrides (only checked for users who don't have mod/quiz:manageoverrides. 'mod/quiz:viewoverrides' => [ 'captype' => 'read', 'contextlevel' => CONTEXT_MODULE, 'archetypes' => [ 'teacher' => CAP_ALLOW, 'editingteacher' => CAP_ALLOW, 'manager' => CAP_ALLOW ] ], // Preview the quiz. 'mod/quiz:preview' => [ 'captype' => 'write', // Only just a write. 'contextlevel' => CONTEXT_MODULE, 'archetypes' => [ 'teacher' => CAP_ALLOW, 'editingteacher' => CAP_ALLOW, 'manager' => CAP_ALLOW ] ], // Manually grade and comment on student attempts at a question. 'mod/quiz:grade' => [ 'riskbitmask' => RISK_SPAM | RISK_XSS, 'captype' => 'write', 'contextlevel' => CONTEXT_MODULE, 'archetypes' => [ 'teacher' => CAP_ALLOW, 'editingteacher' => CAP_ALLOW, 'manager' => CAP_ALLOW ] ], // Regrade quizzes. 'mod/quiz:regrade' => [ 'riskbitmask' => RISK_SPAM, 'captype' => 'write', 'contextlevel' => CONTEXT_MODULE, 'archetypes' => [ 'teacher' => CAP_ALLOW, 'editingteacher' => CAP_ALLOW, 'manager' => CAP_ALLOW ], 'clonepermissionsfrom' => 'mod/quiz:grade' ], // View the quiz reports. 'mod/quiz:viewreports' => [ 'riskbitmask' => RISK_PERSONAL, 'captype' => 'read', 'contextlevel' => CONTEXT_MODULE, 'archetypes' => [ 'teacher' => CAP_ALLOW, 'editingteacher' => CAP_ALLOW, 'manager' => CAP_ALLOW ] ], // Delete attempts using the overview report. 'mod/quiz:deleteattempts' => [ 'riskbitmask' => RISK_DATALOSS, 'captype' => 'write', 'contextlevel' => CONTEXT_MODULE, 'archetypes' => [ 'editingteacher' => CAP_ALLOW, 'manager' => CAP_ALLOW ] ], // Re-open attempts after they are closed. 'mod/quiz:reopenattempts' => [ 'captype' => 'write', 'contextlevel' => CONTEXT_MODULE, 'archetypes' => [ 'editingteacher' => CAP_ALLOW, 'manager' => CAP_ALLOW ] ], // Do not have the time limit imposed. Used for accessibility legislation compliance. 'mod/quiz:ignoretimelimits' => [ 'captype' => 'read', 'contextlevel' => CONTEXT_MODULE, 'archetypes' => [] ], // Receive a confirmation message of own quiz submission. 'mod/quiz:emailconfirmsubmission' => [ 'captype' => 'read', 'contextlevel' => CONTEXT_MODULE, 'archetypes' => [] ], // Receive a notification message of other peoples' quiz submissions. 'mod/quiz:emailnotifysubmission' => [ 'captype' => 'read', 'contextlevel' => CONTEXT_MODULE, 'archetypes' => [] ], // Receive a notification message when a quiz attempt becomes overdue. 'mod/quiz:emailwarnoverdue' => [ 'captype' => 'read', 'contextlevel' => CONTEXT_MODULE, 'archetypes' => [] ], // Receive a notification message when a quiz attempt manual graded. 'mod/quiz:emailnotifyattemptgraded' => [ 'captype' => 'read', 'contextlevel' => CONTEXT_MODULE, 'archetypes' => [] ], ]; PK9B\ db/upgrade.phpnu[. /** * Upgrade script for the quiz module. * * @package mod_quiz * @copyright 2006 Eloy Lafuente (stronk7) * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ /** * Quiz module upgrade function. * @param string $oldversion the version we are upgrading from. */ function xmldb_quiz_upgrade($oldversion) { global $CFG, $DB; $dbman = $DB->get_manager(); // Automatically generated Moodle v4.2.0 release upgrade line. // Put any upgrade step following this. if ($oldversion < 2023042401) { // Define field reviewmaxmarks to be added to quiz. $table = new xmldb_table('quiz'); $field = new xmldb_field('reviewmaxmarks', XMLDB_TYPE_INTEGER, '6', null, XMLDB_NOTNULL, null, '0', 'reviewcorrectness'); // Conditionally launch add field reviewmaxmarks. if (!$dbman->field_exists($table, $field)) { $dbman->add_field($table, $field); } // Quiz savepoint reached. upgrade_mod_savepoint(true, 2023042401, 'quiz'); } // Automatically generated Moodle v4.3.0 release upgrade line. // Put any upgrade step following this. if ($oldversion < 2023112300) { // Set the value for all existing rows to match the previous behaviour, // but only where users have not already set another value. $DB->set_field('quiz', 'reviewmaxmarks', 0x11110, ['reviewmaxmarks' => 0]); // Quiz savepoint reached. upgrade_mod_savepoint(true, 2023112300, 'quiz'); } if ($oldversion < 2023112400) { // Define table quiz_grade_items to be created. $table = new xmldb_table('quiz_grade_items'); // Adding fields to table quiz_grade_items. $table->add_field('id', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, XMLDB_SEQUENCE, null); $table->add_field('quizid', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null); $table->add_field('sortorder', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null); $table->add_field('name', XMLDB_TYPE_CHAR, '255', null, XMLDB_NOTNULL, null, null); // Adding keys to table quiz_grade_items. $table->add_key('primary', XMLDB_KEY_PRIMARY, ['id']); $table->add_key('quizid', XMLDB_KEY_FOREIGN, ['quizid'], 'quiz', ['id']); // Adding indexes to table quiz_grade_items. $table->add_index('quizid-sortorder', XMLDB_INDEX_UNIQUE, ['quizid', 'sortorder']); // Conditionally launch create table for quiz_grade_items. if (!$dbman->table_exists($table)) { $dbman->create_table($table); } // Quiz savepoint reached. upgrade_mod_savepoint(true, 2023112400, 'quiz'); } if ($oldversion < 2023112401) { // Define field quizgradeitemid to be added to quiz_slots. $table = new xmldb_table('quiz_slots'); $field = new xmldb_field('quizgradeitemid', XMLDB_TYPE_INTEGER, '10', null, null, null, null, 'maxmark'); // Conditionally launch add field quizgradeitemid. if (!$dbman->field_exists($table, $field)) { $dbman->add_field($table, $field); } // Quiz savepoint reached. upgrade_mod_savepoint(true, 2023112401, 'quiz'); } if ($oldversion < 2023112402) { // Define key quizgradeitemid (foreign) to be added to quiz_slots. $table = new xmldb_table('quiz_slots'); $key = new xmldb_key('quizgradeitemid', XMLDB_KEY_FOREIGN, ['quizgradeitemid'], 'quiz_grade_items', ['id']); // Launch add key quizgradeitemid. $dbman->add_key($table, $key); // Quiz savepoint reached. upgrade_mod_savepoint(true, 2023112402, 'quiz'); } // 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. if ($oldversion < 2025011300) { // Define field precreateattempts to be added to quiz. $table = new xmldb_table('quiz'); $field = new xmldb_field('precreateattempts', XMLDB_TYPE_INTEGER, '1', null, null, null, null, 'allowofflineattempts'); // Conditionally launch add field precreateattempts. if (!$dbman->field_exists($table, $field)) { $dbman->add_field($table, $field); } upgrade_mod_savepoint(true, 2025011300, 'quiz'); } // Automatically generated Moodle v5.0.0 release upgrade line. // Put any upgrade step following this. return true; } PK9B\'NVVdb/install.xmlnu[
PK9B\{. /** * Add event handlers for the quiz * * @package mod_quiz * @category event * @copyright 2011 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); $observers = [ // Handle group events, so that open quiz attempts with group overrides get updated check times. [ 'eventname' => '\core\event\course_reset_started', 'callback' => '\mod_quiz\group_observers::course_reset_started', ], [ 'eventname' => '\core\event\course_reset_ended', 'callback' => '\mod_quiz\group_observers::course_reset_ended', ], [ 'eventname' => '\core\event\group_deleted', 'callback' => '\mod_quiz\group_observers::group_deleted' ], [ 'eventname' => '\core\event\group_member_added', 'callback' => '\mod_quiz\group_observers::group_member_added', ], [ 'eventname' => '\core\event\group_member_removed', 'callback' => '\mod_quiz\group_observers::group_member_removed', ], // Handle our own \mod_quiz\event\attempt_submitted event, as a way to // send confirmation messages asynchronously. [ 'eventname' => '\mod_quiz\event\attempt_submitted', 'includefile' => '/mod/quiz/locallib.php', 'callback' => 'quiz_attempt_submitted_handler', 'internal' => false ], ]; PK9B\ :B db/messages.phpnu[. /** * Defines message providers (types of message sent) for the quiz module. * * @package mod_quiz * @copyright 2010 Andrew Davis http://moodle.com * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); $messageproviders = [ // Notify teacher that a student has submitted a quiz attempt. 'submission' => [ 'capability' => 'mod/quiz:emailnotifysubmission', 'defaults' => [ 'popup' => MESSAGE_PERMITTED + MESSAGE_DEFAULT_ENABLED, 'email' => MESSAGE_PERMITTED + MESSAGE_DEFAULT_ENABLED, ], ], // Confirm a student's quiz attempt. 'confirmation' => [ 'capability' => 'mod/quiz:emailconfirmsubmission', 'defaults' => [ 'popup' => MESSAGE_PERMITTED + MESSAGE_DEFAULT_ENABLED, 'email' => MESSAGE_PERMITTED + MESSAGE_DEFAULT_ENABLED, 'airnotifier' => MESSAGE_PERMITTED + MESSAGE_DEFAULT_ENABLED, ], ], // Warning to the student that their quiz attempt is now overdue, if the quiz // has a grace period. 'attempt_overdue' => [ 'capability' => 'mod/quiz:emailwarnoverdue', 'defaults' => [ 'popup' => MESSAGE_PERMITTED + MESSAGE_DEFAULT_ENABLED, 'email' => MESSAGE_PERMITTED + MESSAGE_DEFAULT_ENABLED, 'airnotifier' => MESSAGE_PERMITTED + MESSAGE_DEFAULT_ENABLED, ], ], 'attempt_grading_complete' => [ 'capability' => 'mod/quiz:emailnotifyattemptgraded', 'defaults' => [ 'popup' => MESSAGE_PERMITTED + MESSAGE_DEFAULT_ENABLED, 'email' => MESSAGE_PERMITTED + MESSAGE_DEFAULT_ENABLED, 'airnotifier' => MESSAGE_PERMITTED + MESSAGE_DEFAULT_ENABLED, ], ], // Notify of a quiz opening soon. 'quiz_open_soon' => [ 'defaults' => [ 'popup' => MESSAGE_PERMITTED + MESSAGE_DEFAULT_ENABLED, 'email' => MESSAGE_PERMITTED + MESSAGE_DEFAULT_ENABLED, 'airnotifier' => MESSAGE_PERMITTED + MESSAGE_DEFAULT_ENABLED, ], ], ]; PK9B\輌 db/caches.phpnu[. /** * Defined caches used internally by the plugin. * * @package mod_quiz * @copyright 2021 Shamim Rezaie * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ declare(strict_types=1); defined('MOODLE_INTERNAL') || die(); $definitions = [ 'overrides' => [ 'mode' => cache_store::MODE_APPLICATION, 'simplekeys' => true, 'datasource' => '\mod_quiz\cache\overrides', 'invalidationevents' => [ \mod_quiz\local\override_cache::INVALIDATION_USERDATARESET, ], ], ]; PK9B\GLLlang/en/quiz.phpnu[. /** * Strings for component 'mod_quiz', language 'en', branch 'MOODLE_20_STABLE' * * @package mod_quiz * @copyright 1999 onwards Martin Dougiamas {@link http://moodle.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ $string['accessnoticesheader'] = 'You can preview this quiz, but if this were a real attempt, you would be blocked because:'; $string['action'] = 'Action'; $string['activityoverview'] = 'You have quizzes that are due'; $string['adaptive'] = 'Adaptive mode'; $string['adaptive_help'] = 'If enabled, multiple responses to a question are allowed within the same attempt at the quiz. So for example if a response is marked as incorrect, the student will be allowed to try again immediately. However, depending on the "Apply penalties" setting, a penalty will usually be subtracted for each wrong attempt.'; $string['add'] = 'Add'; $string['addaquestion'] = 'a new question'; $string['addasection'] = 'a new section heading'; $string['addarandomquestion'] = 'a random question'; $string['addarandomquestion_help'] = 'When a random question is added, it results in a randomly-chosen question from the category being inserted into the quiz. This means that different students are likely to get a different selection of questions, and when a quiz allows multiple attempts then each attempt is likely to contain a new selection of questions.'; $string['addarandomquestion_success'] = 'Random questions have been added'; $string['addarandomselectedquestion'] = 'Add a random selected question ...'; $string['adddescriptionlabel'] = 'Add a description item'; $string['addingquestion'] = 'Adding a question'; $string['addingquestions'] = '

This side of the page is where you manage your database of questions. Questions are stored in categories to help you keep them organised, and can be used by any quiz in your course or even other courses if you choose to \'publish\' them.

After you select or create a question category you will be able to create or edit questions. You can select any of these questions to add to your quiz over on the other side of this page.

'; $string['addmoreoverallfeedbacks'] = 'Add {no} more feedback fields'; $string['addnewgroupoverride'] = 'Add group override'; $string['addnewpagesafterselected'] = 'Add new pages after selected questions'; $string['addnewquestionsqbank'] = 'Add questions to the category {$a->catname}: {$a->link}'; $string['addnewuseroverride'] = 'Add user override'; $string['addpagebreak'] = 'Add page break'; $string['addpagehere'] = 'Add page here'; $string['addquestion'] = 'Add question'; $string['addquestionfrombankatend'] = 'Add from the question bank at the end'; $string['addquestionfrombanktopage'] = 'Add from the question bank to page {$a}'; $string['addquestions'] = 'Add questions'; $string['addquestionstoquiz'] = 'Add questions to current quiz'; $string['addrandom'] = 'Add {$a} random questions'; $string['addrandomfromcategory'] = 'Add random questions from category:'; $string['addrandomquestion'] = 'Add random question'; $string['addrandomquestionatend'] = 'Add a random question at the end'; $string['addrandomquestiontopage'] = 'Add a random question to page {$a}'; $string['addrandomquestiontoquiz'] = 'Add a random question to quiz {$a}'; $string['addrandom1'] = '<< Add'; $string['addrandom2'] = 'random questions'; $string['addselectedquestionstoquiz'] = 'Add selected questions to the quiz'; $string['addselectedtoquiz'] = 'Add selected to quiz'; $string['addtoquiz'] = 'Add to quiz'; $string['affectedstudents'] = 'Affected {$a}'; $string['aftereachquestion'] = 'After adding each question'; $string['afternquestions'] = 'After adding {$a} questions'; $string['age'] = 'age'; $string['allattempts'] = 'All attempts'; $string['allinone'] = 'Unlimited'; $string['allowreview'] = 'Allow review'; $string['alreadysubmitted'] = 'It is likely that you have already submitted this attempt'; $string['alternativeunits'] = 'Alternative units'; $string['alwaysavailable'] = 'Always available'; $string['analysisoptions'] = 'Analysis options'; $string['analysistitle'] = 'Item analysis table'; $string['answer'] = 'Answer'; $string['answered'] = 'Answered'; $string['answerhowmany'] = 'One or multiple answers?'; $string['answers'] = 'Answers'; $string['answersingleno'] = 'Multiple answers allowed'; $string['answersingleyes'] = 'One answer only'; $string['answertoolong'] = 'Answer too long after line {$a} (255 char. max)'; $string['anytags'] = 'Any tags'; $string['aon'] = 'AON format'; $string['areyousureremoveselected'] = 'Are you sure you want to remove all the selected questions?'; $string['asshownoneditscreen'] = 'As shown on the edit screen'; $string['attempt'] = 'Attempt {$a}'; $string['attemptalreadyclosed'] = 'This attempt has already been finished.'; $string['attemptclosed'] = 'Attempt has not closed yet'; $string['attemptduration'] = 'Duration'; $string['attemptedon'] = 'Attempted on'; $string['attempterror'] = 'You are not allowed to attempt this quiz at this time because: {$a}'; $string['attempterrorinvalid'] = 'Invalid quiz attempt ID'; $string['attempterrorcontentchange'] = 'This quiz preview no longer exists. (When a quiz is edited, any in-progress previews are automatically deleted.)'; $string['attempterrorcontentchangeforuser'] = 'This quiz attempt no longer exists.'; $string['attemptfirst'] = 'First attempt'; $string['attemptgradeddelay'] = 'Attempt graded notification delay'; $string['attemptgradeddelay_desc'] = 'A delay is applied before sending attempt graded notifications to allow time for the teacher to edit the grade.'; $string['attemptincomplete'] = 'That attempt (by {$a}) is not yet completed.'; $string['attemptlast'] = 'Last attempt'; $string['attemptnumber'] = 'Attempt'; $string['attemptquiznow'] = 'Attempt quiz now'; $string['attemptquiz'] = 'Attempt quiz'; $string['attemptreviewtitle'] = '{$a}: Attempt review'; $string['attemptreviewtitlepaged'] = '{$a->name}: Attempt review (page {$a->currentpage} of {$a->totalpages})'; $string['attempts'] = 'Attempts'; $string['attempts_help'] = 'The total number of attempts allowed (not the number of extra attempts).'; $string['attemptsallowed'] = 'Attempts allowed'; $string['attemptselection'] = 'Select which attempts to analyze per user:'; $string['attemptsexist'] = 'You can no longer add or remove questions.'; $string['attemptsnum'] = 'Attempts: {$a}'; $string['attemptsnumthisgroup'] = 'Attempts: {$a->total} ({$a->group} from this group)'; $string['attemptsnumyourgroups'] = 'Attempts: {$a->total} ({$a->group} from your groups)'; $string['attemptsonly'] = 'Show only students with attempts'; $string['attemptstate'] = 'Status'; $string['attemptstillinprogress'] = 'Attempt still in progress'; $string['attemptsummarytitle'] = '{$a}: Attempt summary'; $string['attemptsunlimited'] = 'Unlimited attempts'; $string['attempttitle'] = '{$a}'; $string['attempttitlepaged'] = '{$a->name} (page {$a->currentpage} of {$a->totalpages})'; $string['autosaveperiod'] = 'Auto-save delay'; $string['autosaveperiod_desc'] = 'Responses can be saved automatically during quiz attempts. The responses are saved whenever one is changed, and then after this delay. There is a trade-off: a shorter delay increases the server load, but reduces the chance that students lose their work. If you are going to make this delay much shorter, you should change the value gradually and monitor the server load. If the load gets too high, make the delay longer again. Setting the delay to 0 turns off auto-saving.'; $string['back'] = 'Back to preview question'; $string['backtocourse'] = 'Back to the course'; $string['backtoquestionlist'] = 'Back to question list'; $string['backtoquiz'] = 'Back to quiz editing'; $string['banknotfound'] = 'Question bank not found, please search again'; $string['bestgrade'] = 'Best grade'; $string['bothattempts'] = 'Show students with and without attempts'; $string['browsersecurity'] = 'Browser security'; $string['browsersecurity_help'] = 'If "Full screen pop-up with some JavaScript security" is selected, * The quiz will only start if the student has a JavaScript-enabled web-browser * The quiz appears in a full screen popup window that covers all the other windows and has no navigation controls * Students are prevented, as far as is possible, from using facilities like copy and paste'; $string['cachedef_overrides'] = 'User and group override information'; $string['calculated'] = 'Calculated'; $string['calculatedquestion'] = 'Calculated question not supported at line {$a}. The question will be ignored'; $string['cannotcreatepath'] = 'Path cannot be created ({$a})'; $string['cannoteditafterattempts'] = 'You cannot add or remove questions because this quiz has attempts. ({$a})'; $string['cannotfindprevattempt'] = 'Cannot find previous attempt to build on.'; $string['cannotfindquestionregard'] = 'Failed to get questions for regrading!'; $string['cannotinsert'] = 'Cannot insert question'; $string['cannotinsertrandomquestion'] = 'Could not insert new random question!'; $string['cannotloadquestion'] = 'Could not load question options'; $string['cannotloadtypeinfo'] = 'Unable to load questiontype specific question information'; $string['cannotopen'] = 'Cannot open export file ({$a})'; $string['cannotremoveallsectionslots'] = 'You have selected all questions under the \'{$a}\' section heading. It is not allowed to remove all questions under a section heading.'; $string['cannotremoveslots'] = 'Cannot remove questions'; $string['cannotrestore'] = 'Could not restore question sessions'; $string['cannotreviewopen'] = 'You cannot review this attempt, it is still open.'; $string['cannotsavelayout'] = 'Could not save layout'; $string['cannotsavenumberofquestion'] = 'Could not save number of questions per page'; $string['cannotsavequestion'] = 'Cannot save question list'; $string['cannotsetgrade'] = 'Could not set a new maximum grade for the quiz'; $string['cannotsetsumgrades'] = 'Failed to set sumgrades'; $string['cannotstartgradesmismatch'] = 'Cannot start an attempt at this quiz. The quiz is set to be graded out of {$a->grade}, but none of the questions in the quiz have a grade. This can be fixed on the \'Edit quiz\' page.'; $string['cannotstartmissingquestion'] = 'Cannot start an attempt at this quiz. The quiz definition includes a question that does not exist.'; $string['cannotstartnoquestions'] = 'Cannot start an attempt at this quiz. The quiz has not been set up yet. No questions have been added.'; $string['cannotwrite'] = 'Cannot write to export file ({$a})'; $string['canredoquestions'] = 'Allow redo within an attempt'; $string['canredoquestions_desc'] = 'If enabled, after finishing attempting a question, a \'Try another question like this one\' button is displayed. This allows for a similar question (selected randomly) to be attempted, or the same question again, without the entire quiz attempt having to be submitted and another attempt started. This option is useful for practice quizzes. This setting only affects questions and behaviours (such as immediate feedback or interactive with multiple tries) where it is possible to finish a question before the attempt is submitted.'; $string['canredoquestions_help'] = 'If enabled, after finishing attempting a question, a \'Try another question like this one\' button is displayed. This allows for a similar question (selected randomly) to be attempted, or the same question again, without the entire quiz attempt having to be submitted and another attempt started. This option is useful for practice quizzes. This setting only affects questions and behaviours (such as immediate feedback or interactive with multiple tries) where it is possible to finish a question before the attempt is submitted.'; $string['canredoquestionsyes'] = 'Yes, provide the option to try another question'; $string['caseno'] = 'No, case is unimportant'; $string['casesensitive'] = 'Case sensitivity'; $string['caseyes'] = 'Yes, case must match'; $string['categoryadded'] = 'The category \'{$a}\' was added'; $string['categorydeleted'] = 'The category \'{$a}\' was deleted'; $string['categorynoedit'] = 'You do not have editing privileges in the category \'{$a}\'.'; $string['categoryupdated'] = 'The category was successfully updated'; $string['close'] = 'Close window'; $string['closed'] = 'Closed'; $string['closebeforeopen'] = 'The close date must be after the open date.'; $string['closepreview'] = 'Close preview'; $string['closereview'] = 'Close review'; $string['comment'] = 'Comment'; $string['commentorgrade'] = 'Make comment or override grade'; $string['comments'] = 'Comments'; $string['completedon'] = 'Completed'; $string['completiondetail:minattempts'] = 'Make attempts: {$a}'; $string['completiondetail:passorexhaust'] = 'Receive a pass grade or complete all available attempts'; $string['completionminattempts'] = 'Minimum attempts'; $string['completionminattemptsdesc'] = 'Minimum number of attempts required: {$a}'; $string['completionminattemptserror'] = 'Minimum number of attempts must be lower or equal to attempts allowed.'; $string['completionpassorattemptsexhausteddesc'] = 'Student must achieve a passing grade, or exhaust all available attempts to complete this activity'; $string['completionattemptsexhausted'] = 'Passing grade or all available attempts completed'; $string['completionattemptsexhausted_help'] = 'Mark quiz complete when the student has exhausted the maximum number of attempts.'; $string['configadaptive'] = 'If you choose Yes for this option then the student will be allowed multiple responses to a question even within the same attempt at the quiz.'; $string['configattemptsallowed'] = 'Restriction on the number of attempts students are allowed at the quiz.'; $string['configdecimaldigits'] = 'Number of digits that should be shown after the decimal point when displaying grades.'; $string['configdecimalplaces'] = 'Number of digits that should be shown after the decimal point when displaying grades for the quiz.'; $string['configdecimalplacesquestion'] = 'Number of digits that should be shown after the decimal point when displaying the mark for individual questions.'; $string['configdelaylater'] = 'If you set a time delay here, the student cannot start their third, fourth, ... attempt until this much time has passed since the end of their previous attempt.'; $string['configdelay1'] = 'If you set a time delay, then a student has to wait for that time before they can attempt a quiz after the first attempt.'; $string['configdelay1st2nd'] = 'If you set a time delay here, the student cannot start their second attempt until this much time has passed since the end of their first attempt.'; $string['configdelay2'] = 'If you set a time delay here, then a student has to wait for that time before they can attempt their third or later attempts.'; $string['configeachattemptbuildsonthelast'] = 'If multiple attempts are allowed then each new attempt contains the results of the previous attempt.'; $string['configgrademethod'] = 'When multiple attempts are allowed, which method should be used to calculate the student\'s final grade for the quiz.'; $string['configintro'] = 'The values you set here define the default values that are used in the settings form when you create a new quiz. You can also configure which quiz settings are considered advanced.'; $string['configintroglobal'] = 'These settings control the system-wide behaviour of the Quiz activity.'; $string['configmaximumgrade'] = 'The default grade that the quiz grade is scaled to be out of.'; $string['confignewpageevery'] = 'When adding questions to the quiz page breaks will automatically be inserted according to the setting you choose here.'; $string['confignavmethod'] = 'In Free navigation, questions may be answered in any order using navigation. In Sequential, questions must be answered in strict sequence.'; $string['configoutcomesadvanced'] = 'If this option is turned on, then the Outcomes on the quiz editing form are advanced settings.'; $string['configpenaltyscheme'] = 'Penalty subtracted for each wrong response in adaptive mode.'; $string['configpopup'] = 'Force the attempt to open in a popup window, and use JavaScript tricks to try to restrict copy and paste, etc. during quiz attempts.'; $string['configrequirepassword'] = 'Students must enter this password before they can attempt the quiz.'; $string['configrequiresubnet'] = 'Students can only attempt the quiz from these computers.'; $string['configreviewoptions'] = 'These options control what information users can see when they review a quiz attempt or look at the quiz reports.'; $string['configshowblocks'] = 'Show blocks during quiz attempts.'; $string['configshowuserpicture'] = 'Show the user\'s picture on screen during attempts.'; $string['configshufflewithin'] = 'If you enable this option, then the parts making up the individual questions will be randomly shuffled each time a student starts an attempt at this quiz, provided the option is also enabled in the question settings.'; $string['configtimelimit'] = 'Default time limit for quizzes in minutes. 0 mean no time limit.'; $string['configtimelimitsec'] = 'Default time limit for quizzes in seconds. 0 mean no time limit.'; $string['configurerandomquestion'] = 'Configure question'; $string['confirmclose'] = 'Once you submit your answers, you won’t be able to change them.'; $string['confirmremovequestion'] = 'Are you sure you want to remove this {$a} question?'; $string['confirmremovesectionheading'] = 'Are you sure you want to remove the \'{$a}\' section heading?'; $string['confirmserverdelete'] = 'Are you sure you want to remove the server {$a} from the list?'; $string['connectionok'] = 'Network connection restored. You may continue safely.'; $string['connectionerror'] = 'Network connection lost. (Autosave failed). Make a note of any responses entered on this page in the last few minutes, then try to re-connect. Once connection has been re-established, your responses should be saved and this message will disappear.'; $string['containercategorycreated'] = 'This category has been created to store all the original categories moved to site level due to the causes specified below.'; $string['continueattemptquiz'] = 'Continue your attempt'; $string['continuepreview'] = 'Continue the last preview'; $string['copyingfrom'] = 'Creating a copy of the question \'{$a}\''; $string['copyingquestion'] = 'Copying a question'; $string['correct'] = 'Correct'; $string['correctanswer'] = 'Correct answer'; $string['correctanswerformula'] = 'Correct answer formula'; $string['correctansweris'] = 'Correct answer: {$a}'; $string['correctanswerlength'] = 'Significant figures'; $string['correctanswers'] = 'Correct answers'; $string['correctanswershows'] = 'Correct answer shows'; $string['corrresp'] = 'Correct response'; $string['countdown'] = 'Countdown'; $string['countdownfinished'] = 'The quiz is closing, you should submit your answers now.'; $string['countdowntenminutes'] = 'The quiz will be closing in ten minutes.'; $string['coursetestmanager'] = 'Course Test Manager format'; $string['createcategoryandaddrandomquestion'] = 'Create category and add random question'; $string['createfirst'] = 'You must create some short-answer questions first.'; $string['createmultiple'] = 'Add several random questions to quiz'; $string['createnewquestion'] = 'Create new question'; $string['createquestionandadd'] = 'Create a new question and add it to the quiz.'; $string['currentbank'] = 'Current bank: {$a}'; $string['custom'] = 'Custom format'; $string['dataitemneed'] = 'You need to add at least one set of data items to get a valid question'; $string['datasetdefinitions'] = 'Reusable dataset definitions for category {$a}'; $string['datasetnumber'] = 'Number'; $string['daysavailable'] = 'Days available'; $string['decimaldigits'] = 'Decimal digits in grades'; $string['decimalplaces'] = 'Decimal places in grades'; $string['decimalplaces_help'] = 'This setting specifies the number of digits shown after the decimal point when displaying grades. It only affects the display of grades, not the grades stored in the database, nor the internal calculations, which are carried out to full accuracy.'; $string['decimalplacesquestion'] = 'Decimal places in marks for questions'; $string['decimalplacesquestion_help'] = 'The number of digits shown after the decimal point when displaying the marks for individual questions.'; $string['decimalpoints'] = 'Decimal places'; $string['default'] = 'Default'; $string['defaultgrade'] = 'Default question grade'; $string['defaultinfo'] = 'The default category for questions.'; $string['defaultsettings'] = 'Default quiz settings'; $string['delaylater'] = 'Enforced delay between later attempts'; $string['delaylater_help'] = 'If enabled, a student must wait for the specified time to elapse before attempting the quiz a third time and any subsequent times.'; $string['delay1'] = 'Time delay between first and second attempt'; $string['delay1st2nd'] = 'Enforced delay between 1st and 2nd attempts'; $string['delay1st2nd_help'] = 'If enabled, a student must wait for the specified time to elapse before being able to attempt the quiz a second time.'; $string['delay2'] = 'Time delay between later attempts'; $string['deleteattemptcheck'] = 'Are you absolutely sure you want to completely delete these attempts?'; $string['deleteselected'] = 'Delete selected'; $string['deletingquestionattempts'] = 'Deleting question attempts'; $string['description'] = 'Description'; $string['disabled'] = 'Disabled'; $string['displayoptions'] = 'Display options'; $string['donotuseautosave'] = 'Do not use auto-save'; $string['download'] = 'Click to download the exported category file'; $string['downloadextra'] = '(file is also stored in the course files in the /backupdata/quiz folder)'; $string['dragtoafter'] = 'After {$a}'; $string['dragtostart'] = 'To the start'; $string['duplicateresponse'] = 'This submission has been ignored because you gave an equivalent answer earlier.'; $string['eachattemptbuildsonthelast'] = 'Each attempt builds on the last'; $string['eachattemptbuildsonthelast_help'] = 'If multiple attempts are allowed and this setting is enabled, each new quiz attempt will contain the results of the previous attempt. This allows a quiz to be completed over several attempts.'; $string['edit_slotdisplaynumber_hint'] = 'Edit question number (maximum 16 characters)'; $string['edit_slotdisplaynumber_label'] = 'New value for {$a}'; $string['editcategories'] = 'Edit categories'; $string['editcategory'] = 'Edit category'; $string['editcatquestions'] = 'Edit category questions'; $string['editingquestion'] = 'Editing a question'; $string['editingquiz'] = 'Editing quiz'; $string['editingquiz_help'] = 'When creating a quiz, the main concepts are: * The quiz, containing questions over one or more pages * The question bank, which stores copies of all questions organised into categories * Random questions - A student gets different questions each time they attempt the quiz and different students can get different questions'; $string['editingquiz_link'] = 'mod/quiz/edit'; $string['editingquizx'] = 'Editing quiz: {$a}'; $string['editmaxmark'] = 'Edit maximum mark'; $string['editoverride'] = 'Edit override'; $string['editqcats'] = 'Edit questions categories'; $string['editquestion'] = 'Edit question'; $string['editquestions'] = 'Edit questions'; $string['editquiz'] = 'Edit quiz'; $string['editquizquestions'] = 'Edit quiz questions'; $string['emailconfirmbody'] = 'Hi {$a->username}, Thank you for submitting your answers to \'{$a->quizname}\' in course \'{$a->coursename}\' at {$a->submissiontime}. This message confirms that your answers have been saved. You can access this quiz at {$a->quizurl}.'; $string['emailconfirmbodyautosubmit'] = 'Hi {$a->username}, The time for the quiz \'{$a->quizname}\' in the course \'{$a->coursename}\' expired. Your answers were submitted automatically at {$a->submissiontime}. This message confirms that your answers have been saved. You can access this quiz at {$a->quizurl}.'; $string['emailconfirmsmall'] = 'Thank you for submitting your answers to \'{$a->quizname}\''; $string['emailconfirmautosubmitsmall'] = 'Thank you for submitting your answers to \'{$a->quizname}\''; $string['emailconfirmsubject'] = 'Submission confirmation: {$a->quizname}'; $string['emailnotifybody'] = 'Hi {$a->username}, {$a->studentname} has completed \'{$a->quizname}\' ({$a->quizurl}) in course \'{$a->coursename}\'. You can review this attempt at {$a->quizreviewurl}.'; $string['emailnotifysmall'] = '{$a->studentname} has completed {$a->quizname}. See {$a->quizreviewurl}'; $string['emailnotifysubject'] = '{$a->studentname} has completed {$a->quizname}'; $string['emailmanualgradedbody'] = 'Hi {$a->studentname}, Your answers to \'{$a->quizname}\' in course \'{$a->coursename}\' at {$a->attempttimefinish} have now been graded. You will be able to view your score and feedback by visiting \'{$a->quizurl}\' and reviewing your attempt.'; $string['emailmanualgradedsubject'] = 'Your attempt at {$a->quizname} has been graded'; $string['emailoverduebody'] = 'Hi {$a->studentname}, You started an attempt at \'{$a->quizname}\' in course \'{$a->coursename}\', but you never submitted it. It should have been submitted by {$a->attemptduedate}. If you would still like to submit this attempt, please go to {$a->attemptsummaryurl} and click the submit button. You must do this before {$a->attemptgraceend} otherwise your attempt will not be counted.'; $string['emailoverduesmall'] = 'You did not submit your attempt at {$a->quizname}. Please go to {$a->attemptsummaryurl} before {$a->attemptgraceend} if you would still like to submit.'; $string['emailoverduesubject'] = 'Attempt now overdue: {$a->quizname}'; $string['empty'] = 'Empty'; $string['enabled'] = 'Enabled'; $string['endtest'] = 'Finish attempt ...'; $string['erroraccessingreport'] = 'You cannot access this report'; $string['errorinquestion'] = 'Error in question'; $string['errormissingquestion'] = 'Error: The system is missing the question with id {$a}'; $string['errornotnumbers'] = 'Error - answers must be numeric'; $string['errorunexpectedevent'] = 'Unexpected event code {$a->event} found for question {$a->questionid} in attempt {$a->attemptid}.'; $string['essay'] = 'Essay'; $string['essayquestions'] = 'Questions'; $string['eventattemptautosaved'] = 'Quiz attempt auto-saved'; $string['eventattemptdeleted'] = 'Quiz attempt deleted'; $string['eventattemptmanualgradingcomplete'] = 'Quiz attempt manual grading complete'; $string['eventattemptpreviewstarted'] = 'Quiz attempt preview started'; $string['eventattemptquestionrestarted'] = 'Quiz attempt question restarted'; $string['eventattemptreviewed'] = 'Quiz attempt reviewed'; $string['eventattemptsummaryviewed'] = 'Quiz attempt summary viewed'; $string['eventattemptupdated'] = 'Quiz attempt updated'; $string['eventattemptviewed'] = 'Quiz attempt viewed'; $string['eventeditpageviewed'] = 'Quiz edit page viewed'; $string['eventoverridecreated'] = 'Quiz override created'; $string['eventoverridedeleted'] = 'Quiz override deleted'; $string['eventoverrideupdated'] = 'Quiz override updated'; $string['eventpagebreakcreated'] = 'Page break created'; $string['eventpagebreakdeleted'] = 'Page break deleted'; $string['eventquestionmanuallygraded'] = 'Question manually graded'; $string['eventquizattemptabandoned'] = 'Quiz attempt abandoned'; $string['eventquizattemptgraded'] = 'Quiz attempt graded'; $string['eventquizattemptregraded'] = 'Quiz attempt regraded'; $string['eventquizattemptreopened'] = 'Quiz attempt reopened'; $string['eventquizattemptstarted'] = 'Quiz attempt started'; $string['eventquizattemptsubmitted'] = 'Quiz attempt submitted'; $string['eventquizattempttimelimitexceeded'] = 'Quiz attempt time limit exceeded'; $string['eventquizgradeitemcreated'] = 'Quiz grade item created'; $string['eventquizgradeitemdeleted'] = 'Quiz grade item deleted'; $string['eventquizgradeitemorderchanged'] = 'Quiz grade item order changed'; $string['eventquizgradeitemupdated'] = 'Quiz grade item updated'; $string['eventquizgradeupdated'] = 'Quiz grade updated'; $string['eventquizrepaginated'] = 'Quiz re-paginated'; $string['eventreportviewed'] = 'Quiz report viewed'; $string['eventsectionbreakcreated'] = 'Section break created'; $string['eventsectionbreakdeleted'] = 'Section break deleted'; $string['eventsectiontitleupdated'] = 'Section title updated'; $string['eventsectionshuffleupdated'] = 'Section shuffle updated'; $string['eventslotcreated'] = 'Slot created'; $string['eventslotdeleted'] = 'Slot deleted'; $string['eventslotdisplayedquestionnumberupdated'] = 'Slot displayed question number updated'; $string['eventslotgradeitemupdated'] = 'Slot grade item updated'; $string['eventslotmarkupdated'] = 'Slot mark updated'; $string['eventslotversionupdated'] = 'Slot version updated'; $string['eventslotmoved'] = 'Slot moved'; $string['eventslotrequirepreviousupdated'] = 'Slot require previous updated'; $string['everynquestions'] = 'Every {$a} questions'; $string['everyquestion'] = 'Every question'; $string['everythingon'] = 'Everything on'; $string['existingcategory'] = 'Existing category'; $string['exportcategory'] = 'export category'; $string['exporterror'] = 'An error occurred during export processing'; $string['exportingquestions'] = 'Questions are being exported to file'; $string['exportname'] = 'File name'; $string['exportquestions'] = 'Export questions to file'; $string['extraattemptrestrictions'] = 'Extra restrictions on attempts'; $string['false'] = 'False'; $string['feedback'] = 'Feedback'; $string['feedbackerrorboundaryformat'] = 'Feedback grade boundaries must be either a percentage or a number. The value you entered in boundary {$a} is not recognised.'; $string['feedbackerrorboundaryoutofrange'] = 'Feedback grade boundaries must be between 0% and 100%. The value you entered in boundary {$a} is out of range.'; $string['feedbackerrorjunkinboundary'] = 'You must fill in the feedback grade boundary boxes without leaving any gaps.'; $string['feedbackerrorjunkinfeedback'] = 'You must fill in the feedback boxes without leaving any gaps.'; $string['feedbackerrororder'] = 'Feedback grade boundaries must be in order, highest first. The value you entered in boundary {$a} is out of sequence.'; $string['file'] = 'File'; $string['fileformat'] = 'File format'; $string['fillcorrect'] = 'Fill with correct'; $string['filloutnumericalanswer'] = 'You provide at least one possible answer and tolerance. The first matching answer will be used to determine the grade and feedback. If you supply some feedback with no answer at the end, that will be shown to students whose response is not matched by any of the other answers.'; $string['filloutoneanswer'] = 'You must provide at least one possible answer. Answers left blank will not be used. \'*\' can be used as a wildcard to match any characters. The first matching answer will be used to determine the grade and feedback.'; $string['filloutthreequestions'] = 'You must provide at least three questions with matching 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['fillouttwochoices'] = 'You must fill out at least two choices. Choices left blank will not be used.'; $string['finishattemptdots'] = 'Finish attempt...'; $string['finishreview'] = 'Finish review'; $string['forceregeneration'] = 'force regeneration'; $string['formatnotfound'] = 'Import/export format {$a} not found'; $string['formulaerror'] = 'Formula errors!'; $string['fractionsaddwrong'] = 'The positive grades you have chosen do not add up to 100%
Instead, they add up to {$a}%
Do you want to go back and fix this question?'; $string['fractionsnomax'] = 'One of the answers should be 100%, so that it is
possible to get a full grade for this question.
Do you want to go back and fix this question?'; $string['fromfile'] = 'from file:'; $string['functiondisabledbysecuremode'] = 'That functionality is currently disabled'; $string['generalfeedback'] = 'General feedback'; $string['generalfeedback_help'] = 'General feedback is text which is shown after a question has been attempted. Unlike feedback for a specific question which depends on the response given, the same general feedback is always shown.'; $string['graceperiod'] = 'Submission grace period'; $string['graceperiod_desc'] = 'If what to do when the time expires is set to \'There is a grace period...\', then this is the default amount of extra time that is allowed.'; $string['graceperiod_help'] = 'If what to do when the time expires is set to \'There is a grace period...\', then this is the amount of extra time that is allowed.'; $string['graceperiodmin'] = 'Last submission grace period'; $string['graceperiodmin_desc'] = 'There is a potential problem right at the end of the quiz. On the one hand, we want to let students continue working right up until the last second - with the help of the timer that automatically submits the quiz when time runs out. On the other hand, the server may then be overloaded, and take some time to get to process the responses. Therefore, we will accept responses for up to this long after time expires, so they are not penalised for the server being slow. However, the student could cheat and get this many seconds to answer the quiz. You have to make a trade-off based on how much you trust the performance of your server during quizzes.'; $string['graceperiodtoosmall'] = 'The grace period must be more than {$a}.'; $string['gradeall'] = 'Grade all'; $string['gradeaverage'] = 'Average grade'; $string['gradeboundary'] = 'Grade boundary'; $string['gradeessays'] = 'Grade essays'; $string['gradehighest'] = 'Highest grade'; $string['gradeitemdefaultname'] = 'New grade item {$a}'; $string['gradeitemdelete'] = 'Delete grade item {$a}'; $string['gradeitemedit'] = 'Edit name of grade item {$a}'; $string['gradeitemmarkscheme'] = 'Assign grade items'; $string['gradeitemnewname'] = 'New name for grade item {$a}'; $string['gradeitemnoneselected'] = '[none]'; $string['gradeitemnoslots'] = 'This quiz has no questions yet. Please add questions first, then return here to set up grade items.'; $string['gradeitems'] = 'Grade items'; $string['gradeitemsautosetup'] = 'Set up a grade for each section'; $string['gradeitemsetup'] = 'Grade items setup'; $string['gradeitemsnoneyet'] = 'Create grade items within your quiz. Allocate questions or quiz sections to these grade items to break down grade results into different areas.'; $string['gradeitemsremoveall'] = 'Reset setup'; $string['gradeitemsremoveallconfirm'] = 'Reset grade items setup?'; $string['gradeitemsremoveallmessage'] = 'This will delete all grade items and unassign questions and sections from them.

This action will not affect the questions and sections themselves, nor existing attempts within the quiz.'; $string['gradeitemsremovealltitle'] = 'Reset grade items setup'; $string['grademethod'] = 'Grading method'; $string['grademethod_help'] = 'When multiple attempts are allowed, the following methods are available for calculating the final quiz grade: * Highest grade of all attempts * Average (mean) grade of all attempts * First attempt (all other attempts are ignored) * Last attempt (all other attempts are ignored)'; $string['gradesofar'] = '{$a->method}: {$a->mygrade} / {$a->quizgrade}.'; $string['gradetopassmustbeset'] = 'Grade to pass cannot be zero as this quiz has its completion method set to require passing grade. Please set a non-zero value.'; $string['gradetopassoutof'] = 'Grade to pass: {$a->grade} out of {$a->maxgrade}'; $string['gradingdetails'] = 'Marks for this submission: {$a->raw}/{$a->max}.'; $string['gradingdetailsadjustment'] = 'With previous penalties this gives {$a->cur}/{$a->max}.'; $string['gradingdetailspenalty'] = 'This submission attracted a penalty of {$a}.'; $string['gradingdetailszeropenalty'] = 'You were not penalized for this submission.'; $string['gradingmethod'] = 'Grading method: {$a}'; $string['groupoverrides'] = 'Group overrides'; $string['groupsnone'] = 'There are no groups in this course.'; $string['guestsno'] = 'Sorry, guests cannot see or attempt quizzes'; $string['hidebreaks'] = 'Hide page breaks'; $string['hidereordertool'] = 'Hide the reordering tool'; $string['history'] = 'History of responses:'; $string['howquestionsbehave_desc'] = 'Default setting for how questions behave in a quiz.'; $string['imagedisplay'] = 'Image to display'; $string['import_help'] = 'This function allows you to import questions from external text files. If your file contains non-ascii characters then it must use UTF-8 encoding. Be particularly cautious with files generated by Microsoft Office applications, as these commonly use special encoding which will not be handled correctly. Import and Export formats are a pluggable resource. Other optional formats may be available in the Modules and Plugins database.'; $string['import_link'] = 'question/import'; $string['importcategory'] = 'import category'; $string['importerror'] = 'An error occurred during import processing'; $string['importfilearea'] = 'Import from file already in course files...'; $string['importfileupload'] = 'Import from file upload...'; $string['importfromthisfile'] = 'Import from this file'; $string['importingquestions'] = 'Importing {$a} questions from file'; $string['importmaxerror'] = 'There is an error in the question. There are too many answers.'; $string['importmax10error'] = 'There is an error in the question. You may not have more than ten answers'; $string['importquestions'] = 'Import questions from file'; $string['inactiveoverridehelp'] = '* This override is inactive because the user\'s access to the activity is restricted. This can be due to group or role assignments, other access restrictions, or the activity being hidden.'; $string['incorrect'] = 'Incorrect'; $string['indicator:cognitivedepth'] = 'Quiz cognitive'; $string['indicator:cognitivedepth_help'] = 'This indicator is based on the cognitive depth reached by the student in a Quiz activity.'; $string['indicator:cognitivedepthdef'] = 'Quiz cognitive'; $string['indicator:cognitivedepthdef_help'] = 'The participant has reached this percentage of the cognitive engagement offered by the Quiz activities during this analysis interval (Levels = No view, View, Submit, View feedback, Comment on feedback, Resubmit after viewing feedback)'; $string['indicator:cognitivedepthdef_link'] = 'Learning_analytics_indicators#Cognitive_depth'; $string['indicator:socialbreadth'] = 'Quiz social'; $string['indicator:socialbreadth_help'] = 'This indicator is based on the social breadth reached by the student in a Quiz activity.'; $string['indicator:socialbreadthdef'] = 'Quiz social'; $string['indicator:socialbreadthdef_help'] = 'The participant has reached this percentage of the social engagement offered by the Quiz activities during this analysis interval (Levels = No participation, Participant alone, Participant with others)'; $string['indicator:socialbreadthdef_link'] = 'Learning_analytics_indicators#Social_breadth'; $string['indivresp'] = 'Responses of individuals to each item'; $string['info'] = 'Info'; $string['infoshort'] = 'i'; $string['initialnumfeedbacks'] = 'Initial number of overall feedback fields'; $string['initialnumfeedbacks_desc'] = 'When creating a new quiz, provide this many blank overall feedback boxes. Once the quiz has been created, the form shows the number of fields required for the number of feedbacks in the quiz. The setting must be at least 1.'; $string['inprogress'] = 'In progress'; $string['introduction'] = 'Description'; $string['invalidattemptid'] = 'No such attempt ID exists'; $string['invalidcategory'] = 'Category ID is invalid'; $string['invalidoverrideid'] = 'Invalid override ID.'; $string['invalidquestionid'] = 'Invalid question id'; $string['invalidquizid'] = 'Invalid quiz ID'; $string['invalidrandomslot'] = 'Invalid random question slot id.'; $string['invalidsource'] = 'The source is not accepted as valid.'; $string['invalidsourcetype'] = 'Invalid source type.'; $string['invalidstateid'] = 'Invalid state id'; $string['lastanswer'] = 'Your last answer was'; $string['lastautosave'] = 'Last saved: {$a}'; $string['layout'] = 'Layout'; $string['layoutasshown'] = 'Page layout as shown.'; $string['layoutasshownwithpages'] = 'Page layout as shown. (Automatic new page every {$a} questions.)'; $string['layoutshuffledandpaged'] = 'Questions randomly shuffled with {$a} questions per page.'; $string['layoutshuffledsinglepage'] = 'Questions randomly shuffled, all on one page.'; $string['link'] = 'Link'; $string['listitems'] = 'Listing of items in quiz'; $string['literal'] = 'Literal'; $string['loadingquestionsfailed'] = 'Loading questions failed: {$a}'; $string['makecopy'] = 'Save as new question'; $string['managetypes'] = 'Manage question types and servers'; $string['manualgrading'] = 'Grading'; $string['manualgradequestion'] = 'Manually grade question {$a->question} in {$a->quiz} by {$a->user}'; $string['mark'] = 'Submit'; $string['markall'] = 'Submit page'; $string['marks'] = 'Marks'; $string['marks_help'] = 'The mark obtained for each question and the overall attempt score. You can only select Marks if Maximum marks is selected.'; $string['match'] = 'Matching'; $string['matchanswer'] = 'Matching answer'; $string['matchanswerno'] = 'Matching answer {$a}'; $string['messageprovider:attempt_overdue'] = 'Warning when your quiz attempt becomes overdue'; $string['messageprovider:confirmation'] = 'Confirmation of your own quiz submissions'; $string['messageprovider:attempt_grading_complete'] = 'Notification that your attempt has been graded'; $string['messageprovider:quiz_open_soon'] = 'Quiz opens soon'; $string['messageprovider:submission'] = 'Notification of your students\' quiz submissions'; $string['max'] = 'Max'; $string['maxmark'] = 'Maximum mark'; $string['maxmarks'] = 'Maximum marks'; $string['maxmarks_help'] = 'The maximum mark available for each question.'; $string['min'] = 'Min'; $string['minutes'] = 'Minutes'; $string['missingcategory'] = 'Missing question category'; $string['missingcorrectanswer'] = 'Correct answer must be specified'; $string['missingitemtypename'] = 'Missing name'; $string['missingquestion'] = 'This question no longer seems to exist'; $string['modulename'] = 'Quiz'; $string['modulename_help'] = 'The quiz activity enables a teacher to create quizzes comprising questions of various types, including multiple choice, matching, short-answer and numerical. The teacher can allow the quiz to be attempted multiple times, with the questions shuffled or randomly selected from the question bank. A time limit may be set. Each attempt is marked automatically, with the exception of essay questions, and the grade is recorded in the gradebook. The teacher can choose when and if hints, feedback and correct answers are shown to students. Quizzes may be used * As course exams * As mini tests for reading assignments or at the end of a topic * As exam practice using questions from past exams * To deliver immediate feedback about performance * For self-assessment'; $string['modulename_link'] = 'mod/quiz/view'; $string['modulenameplural'] = 'Quizzes'; $string['moveselectedonpage'] = 'Move selected questions to page: {$a}'; $string['multichoice'] = 'Multiple choice'; $string['multipleanswers'] = 'Choose at least one answer.'; $string['mustbesubmittedby'] = 'This attempt must be submitted by {$a}.'; $string['name'] = 'Name'; $string['navigatenext'] = 'Next page'; $string['navigateprevious'] = 'Previous page'; $string['navmethod'] = 'Navigation method'; $string['navmethod_free'] = 'Free'; $string['navmethod_help'] = 'When sequential navigation is enabled a student must progress through the quiz in order and may not return to previous pages nor skip ahead.'; $string['navmethod_seq'] = 'Sequential'; $string['navnojswarning'] = 'Warning: these links will not save your answers. Use the next button at the bottom of the page.'; $string['neverallononepage'] = 'Never, all questions on one page'; $string['newattemptfail'] = 'Error: Could not start a new attempt at the quiz'; $string['newcategory'] = 'New category'; $string['newpage'] = 'New page'; $string['newpage_help'] = 'For longer quizzes it makes sense to stretch the quiz over several pages by limiting the number of questions per page. When adding questions to the quiz, page breaks will automatically be inserted according to this setting. However page breaks may later be moved manually on the editing page.'; $string['newpageevery'] = 'Automatically start a new page'; $string['newsectionheading'] = 'New heading'; $string['noanswers'] = 'No answers were selected!'; $string['noattempts'] = 'No attempts have been made on this quiz'; $string['noattemptsfound'] = 'No attempts found.'; $string['noattemptstoshow'] = 'There are no attempts to show'; $string['nocategory'] = 'Incorrect or no category specified'; $string['noclose'] = 'No close date'; $string['nocommentsyet'] = 'No comments yet.'; $string['noconnection'] = 'There is currently no connection to a web service that can process this question. Please contact your administrator'; $string['nodataset'] = 'nothing - it is not a wild card'; $string['nodatasubmitted'] = 'No data was submitted.'; $string['noessayquestionsfound'] = 'No manually graded questions found'; $string['nogradewarning'] = 'This quiz is not graded, so you cannot set overall feedback that differs by grade.'; $string['nomoreattempts'] = 'No more attempts are allowed'; $string['none'] = 'None'; $string['noopen'] = 'No open date'; $string['nooverridedata'] = 'You must override at least one of the quiz settings.'; $string['nopossibledatasets'] = 'No possible datasets'; $string['noquestionintext'] = 'The question text does not contain any embedded questions'; $string['noquestions'] = 'No questions have been added yet'; $string['noquestionsfound'] = 'No questions found'; $string['noquestionsinquiz'] = 'There are no questions in this quiz.'; $string['noquestionsnotinuse'] = 'This random question is not in use, since its category is empty.'; $string['noquestionsonpage'] = 'Empty page'; $string['noresponse'] = 'No response'; $string['noreview'] = 'You are not allowed to review this quiz'; $string['noreviewattempt'] = 'You are not allowed to review this attempt.'; $string['noreviewshort'] = 'Review not permitted'; $string['noreviewuntil'] = 'You are not allowed to review this quiz until {$a}'; $string['noreviewuntilshort'] = 'Available {$a}'; $string['noscript'] = 'JavaScript must be enabled to continue!'; $string['notavailabletostudents'] = 'Note: This quiz is currently not available to your students.'; $string['notenoughrandomquestions'] = 'There are not enough questions in category {$a->category} to create the question {$a->name} ({$a->id}).'; $string['notenoughsubquestions'] = 'Not enough sub-questions have been defined!
Do you want to go back and fix this question?'; $string['notifyattemptsgradedtask'] = 'Send quiz attempt graded notifications'; $string['notimedependentitems'] = 'Time dependent items are not currently supported by the quiz module. As a work around, set a time limit for the whole quiz. Do you wish to choose a different item (or use the current item regardless)?'; $string['notyetgraded'] = 'Not yet graded'; $string['notyetviewed'] = 'Not yet viewed'; $string['notyourattempt'] = 'This is not your attempt!'; $string['noview'] = 'Logged-in user is not allowed to view this quiz'; $string['numattempts'] = '{$a->studentnum} {$a->studentstring} have made {$a->attemptnum} attempts'; $string['numberabbr'] = '#'; $string['numerical'] = 'Numerical'; $string['numquestionsx'] = 'Questions: {$a}'; $string['oneminute'] = '1 minute'; $string['onlyteachersexport'] = 'Only teachers can export questions'; $string['onlyteachersimport'] = 'Only teachers with editing rights can import questions'; $string['onthispage'] = 'This page'; $string['open'] = 'Not answered'; $string['openafterclose'] = 'Could not update the quiz. You have specified an open date after the close date.'; $string['openclosedatesupdated'] = 'Open and close dates'; $string['optional'] = 'optional'; $string['orderandpaging'] = 'Order and paging'; $string['orderandpaging_help'] = 'The numbers 10, 20, 30, ... opposite each question indicate the order of the questions. The numbers increase in steps of 10 to leave space for additional questions to be inserted. To reorder the questions, change the numbers then click the "Reorder questions" button. To add page breaks after particular questions, tick the checkboxes next to the questions then click the "Add new pages after selected questions" button. To arrange the questions over a number of pages, click the Repaginate button and select the desired number of questions per page.'; $string['orderingquiz'] = 'Order and paging'; $string['orderingquizx'] = 'Order and paging: {$a}'; $string['outcomesadvanced'] = 'Outcomes are advanced settings'; $string['outof'] = '{$a->grade} out of {$a->maxgrade}'; $string['outofpercent'] = '{$a->grade} out of {$a->maxgrade} ({$a->percent}%)'; $string['outofshort'] = '{$a->grade}/{$a->maxgrade}'; $string['overallfeedback'] = 'Overall feedback'; $string['overallfeedback_help'] = 'Overall feedback is text that is shown after a quiz has been attempted. By specifying additional grade boundaries (as a percentage or as a number), the text shown can depend on the grade obtained.'; $string['overdue'] = 'Overdue'; $string['overduehandling'] = 'When time expires'; $string['overduehandling_desc'] = 'What should happen by default if a student does not submit the quiz before time expires.'; $string['overduehandling_help'] = 'This setting controls what happens if a student fails to submit their quiz attempt before the time expires. If the student is actively working on the quiz at the time, then the countdown timer will always automatically submit the attempt for them, but if they have logged out, then this setting controls what happens.'; $string['overduehandling_link'] = 'mod/quiz/timing'; $string['overduehandlingautosubmit'] = 'Open attempts are submitted automatically'; $string['overduehandlinggraceperiod'] = 'There is a grace period when open attempts can be submitted, but no more questions answered'; $string['overduehandlingautoabandon'] = 'Attempts must be submitted before time expires, or they are not counted'; $string['overduemustbesubmittedby'] = 'This attempt is now overdue. It should already have been submitted. If you would like this quiz to be graded, you must submit it by {$a}. If you do not submit it by then, no marks from this attempt will be counted.'; $string['override'] = 'Override'; $string['overridecannotchange'] = 'The user or group cannot be changed after an override is created.'; $string['overridecannotsetbothgroupanduser'] = 'Both group and user cannot be set at the same time.'; $string['overridedeletegroupsure'] = 'Are you sure you want to delete the override for group {$a}?'; $string['overridedeleteusersure'] = 'Are you sure you want to delete the override for user {$a}?'; $string['overridegroup'] = 'Override group'; $string['overridegroupeventname'] = '{$a->quiz} - {$a->group}'; $string['overrideinvalidattempts'] = 'Attempts value must be greater than zero.'; $string['overrideinvalidexistingid'] = 'Existing override doesn\'t exist.'; $string['overrideinvalidgroup'] = 'Group given doesn\'t exist.'; $string['overrideinvalidquiz'] = 'Quiz ID set doesn\'t exist.'; $string['overrideinvalidtimelimit'] = 'Time limit must be greater than zero.'; $string['overrideinvaliduser'] = 'User given doesn\'t exist.'; $string['overridemissingdelete'] = 'Override ID(s) {$a} couldn\'t be deleted because they don\'t exist or are not a part of the given quiz.'; $string['overridemultiplerecordsexist'] = 'Multiple overrides cannot be made for the same user/group.'; $string['overridemustsetuserorgroup'] = 'A user or group must be set.'; $string['overrides'] = 'Overrides'; $string['overridesforquiz'] = 'Settings overrides: {$a}'; $string['overridesnoneforgroups'] = 'There are no group overrides for this quiz.'; $string['overridesnoneforusers'] = 'There are no user overrides for this quiz.'; $string['overridessummary'] = 'Settings overrides exist ({$a})'; $string['overridessummarythisgroup'] = 'Settings overrides exist ({$a}) for this group'; $string['overridessummaryyourgroups'] = 'Settings overrides exist ({$a}) for your groups'; $string['overridessummarygroup'] = 'Groups: {$a}'; $string['overridessummaryuser'] = 'Users: {$a}'; $string['overrideuser'] = 'Override user'; $string['overrideusereventname'] = '{$a->quiz} - Override'; $string['pageshort'] = 'P'; $string['page-mod-quiz-x'] = 'Any quiz module page'; $string['page-mod-quiz-attempt'] = 'Attempt quiz page'; $string['page-mod-quiz-edit'] = 'Edit quiz page'; $string['page-mod-quiz-report'] = 'Any quiz report page'; $string['page-mod-quiz-review'] = 'Review quiz attempt page'; $string['page-mod-quiz-summary'] = 'Quiz attempt summary page'; $string['page-mod-quiz-view'] = 'Quiz information page'; $string['pagesize'] = 'Page size'; $string['parent'] = 'Parent'; $string['parentcategory'] = 'Parent category'; $string['parsingquestions'] = 'Parsing questions from import file.'; $string['partiallycorrect'] = 'Partially correct'; $string['penalty'] = 'Penalty'; $string['penaltyscheme'] = 'Apply penalties'; $string['penaltyscheme_help'] = 'If enabled, a penalty is subtracted from the final mark for a question for a wrong response. The amount of penalty is specified in the question settings. This setting only applies if adaptive mode is enabled.'; $string['percentcorrect'] = 'Percent correct'; $string['pleaseclose'] = 'Your request has been processed. You can now close this window'; $string['pluginadministration'] = 'Quiz administration'; $string['pluginname'] = 'Quiz'; $string['popup'] = 'Show quiz in a \'secure\' window'; $string['popupblockerwarning'] = 'This section of the test is in secure mode, this means that you need to take the quiz in a secure window. Please turn off your popup blocker. Thank you.'; $string['popupnotice'] = 'Students will see this quiz in a secure window'; $string['precreateattempts'] = 'Pre-create attempts'; $string['precreateattempts_desc'] = 'Pre-creating attempts helps optimise site performance. In addition to enabling this setting, you need to set a pre-create attempts period. Note: The setting only applies to quizzes with an opening time.'; $string['precreateattempts_help'] = 'Pre-creating attempts helps optimise site performance. Quiz questions cannot be modified once a quiz has attempts.'; $string['precreateoff'] = 'Do not pre-create attempts'; $string['precreateperiod'] = 'Pre-create attempts period'; $string['precreateperiod_desc'] = 'If your site has quizzes with an opening time and many students starting at the same time, you can improve performance by pre-creating attempts. Select a time period before the quiz opens to pre-create attempts. A shorter period is recommended, as quiz questions cannot be modified once a quiz has attempts. Attempts are generated by a scheduled task running each hour by default. To pre-create attempts when the system is quiet such as overnight, set the period to 12 hours and adjust the scheduled task to run during quieter hours.'; $string['precreatetask'] = 'Pre-create attempts'; $string['precreateusedefault'] = 'Use site-wide default ({$a})'; $string['precreateyes'] = 'Yes, {$a} hours before quiz open time'; $string['preprocesserror'] = 'Error occurred during pre-processing!'; $string['preview'] = 'Preview'; $string['previewquestion'] = 'Preview question'; $string['previewquiz'] = 'Preview {$a}'; $string['previewquizstart'] = 'Preview quiz'; $string['previewquiznow'] = 'Preview quiz now'; $string['previous'] = 'Previous state'; $string['privacy:metadata:core_question'] = 'The quiz activity stores question usage information in the core_question subsystem.'; $string['privacy:metadata:quiz'] = 'The quiz activity makes use of quiz reports.'; $string['privacy:metadata:quiz_attempts'] = 'Details about each attempt on a quiz.'; $string['privacy:metadata:quiz_attempts:attempt'] = 'The attempt number.'; $string['privacy:metadata:quiz_attempts:currentpage'] = 'The current page that the user is on.'; $string['privacy:metadata:quiz_attempts:gradednotificationsenttime'] = 'The time the user was notified that manual grading of their attempt was complete'; $string['privacy:metadata:quiz_attempts:preview'] = 'Whether this is a preview of the quiz.'; $string['privacy:metadata:quiz_attempts:state'] = 'The current state of the attempt.'; $string['privacy:metadata:quiz_attempts:sumgrades'] = 'The sum of grades in the attempt.'; $string['privacy:metadata:quiz_attempts:timecheckstate'] = 'The time that the state was checked.'; $string['privacy:metadata:quiz_attempts:timefinish'] = 'The time that the attempt was completed.'; $string['privacy:metadata:quiz_attempts:timemodified'] = 'The time that the attempt was updated.'; $string['privacy:metadata:quiz_attempts:timemodifiedoffline'] = 'The time that the attempt was updated via an offline update.'; $string['privacy:metadata:quiz_attempts:timestart'] = 'The time that the attempt was started.'; $string['privacy:metadata:quiz_grades'] = 'Details about the overall grade for this quiz.'; $string['privacy:metadata:quiz_grades:grade'] = 'The overall grade for this quiz.'; $string['privacy:metadata:quiz_grades:quiz'] = 'The quiz that was graded.'; $string['privacy:metadata:quiz_grades:timemodified'] = 'The time that the grade was modified.'; $string['privacy:metadata:quiz_grades:userid'] = 'The user who was graded.'; $string['privacy:metadata:quiz_overrides'] = 'Details about overrides for this quiz'; $string['privacy:metadata:quiz_overrides:quiz'] = 'The quiz with override information'; $string['privacy:metadata:quiz_overrides:timeclose'] = 'The new close time for the quiz.'; $string['privacy:metadata:quiz_overrides:timelimit'] = 'The new time limit for the quiz.'; $string['privacy:metadata:quiz_overrides:timeopen'] = 'The new open time for the quiz.'; $string['privacy:metadata:quiz_overrides:userid'] = 'The user being overridden'; $string['privacy:metadata:quizaccess'] = 'The quiz activity makes use of quiz access rules.'; $string['publish'] = 'Publish'; $string['publishedit'] = 'You must have permission in the publishing course to add or edit questions in this category'; $string['qbrief'] = 'Q. {$a}'; $string['qname'] = 'name'; $string['qti'] = 'IMS QTI format'; $string['qtypename'] = 'type, name'; $string['question'] = 'Question'; $string['questionbank'] = 'from question bank'; $string['questionbankmanagement'] = 'Question bank management'; $string['questionbehaviour'] = 'Question behaviour'; $string['questioncats'] = 'Question categories'; $string['questiondeleted'] = 'This question has been deleted. Please contact your teacher.'; $string['questiondependencyadd'] = 'No restriction on when question {$a->thisq} can be attempted • Click to change'; $string['questiondependencyfree'] = 'No restriction on this question'; $string['questiondependencyremove'] = 'Question {$a->thisq} cannot be attempted until the previous question {$a->previousq} has been completed • Click to change'; $string['questiondependsonprevious'] = 'This question cannot be attempted until the previous question has been completed.'; $string['questiondraftonly'] = 'The question {$a} is in draft status. To use it in the quiz, go to the question bank and change the status to ready.'; $string['questiondraftwillnotwork'] = 'This question is in draft status. To use it in the quiz, go to the question bank and change the status to ready.'; $string['questioninuse'] = 'The question \'{$a->questionname}\' is currently being used in:
{$a->quiznames}
The question will not be deleted from these quizzes but only from the category list.'; $string['questionmissing'] = 'Question for this session is missing'; $string['questionname'] = 'Question name'; $string['questionnonav'] = 'Question {$a->number} {$a->attributes}'; $string['questionnonavinfo'] = 'Information {$a->number} {$a->attributes}'; $string['questionnotloaded'] = 'Question {$a} has not been loaded from the database'; $string['questionorder'] = 'Question order'; $string['questionposition'] = 'New position in order for question {$a}'; $string['questions'] = 'Questions'; $string['questionsetpreview'] = 'Question set preview'; $string['questionsinclhidden'] = 'Questions (including hidden)'; $string['questionsinthisquiz'] = 'Questions in this quiz'; $string['questionsmatchingfilter'] = 'Questions matching this filter: {$a}'; $string['questionsperpage'] = 'Questions per page'; $string['questionsperpageselected'] = 'Questions per page has been set so the paging is currently fixed. As a result, the paging controls have been disabled. You can change this in {$a}.'; $string['questionsperpagex'] = 'Questions per page: {$a}'; $string['questiontext'] = 'Question text'; $string['questiontextisempty'] = '[Empty question text]'; $string['questiontype'] = 'Question type {$a}'; $string['questiontypesetupoptions'] = 'Setup options for question types:'; $string['quiz:addinstance'] = 'Add a new quiz'; $string['quiz:attempt'] = 'Attempt quizzes'; $string['quizavailable'] = 'The quiz is available until: {$a}'; $string['quizclose'] = 'Close the quiz'; $string['quizclosed'] = 'This quiz closed on {$a}'; $string['quizcloses'] = 'Quiz closes'; $string['quizeventcloses'] = '{$a} closes'; $string['quizcloseson'] = 'This quiz will close on {$a}.'; $string['quiz:deleteattempts'] = 'Delete quiz attempts'; $string['quiz:emailconfirmsubmission'] = 'Receive confirmation of your own quiz submissions'; $string['quiz:emailnotifysubmission'] = 'Receive notification of your students\' quiz submissions'; $string['quiz:emailnotifyattemptgraded'] = 'Receive notification when your attempt has been graded'; $string['quiz:emailwarnoverdue'] = 'Receive warning when your quiz attempt becomes overdue'; $string['quiz:grade'] = 'Grade quizzes manually'; $string['quiz:ignoretimelimits'] = 'Ignore quiz time limit'; $string['quizinvalidquestions'] = 'This quiz has questions with invalid types. The missing question type must be reinstalled or the affected questions removed, before the quiz can be used.'; $string['quizisclosed'] = 'This quiz is closed'; $string['quizisopen'] = 'This quiz is open'; $string['quizisclosedwillopen'] = 'Quiz closed (opens {$a})'; $string['quizisopenwillclose'] = 'Quiz open (closes {$a})'; $string['quiz:manage'] = 'Manage quizzes'; $string['quiz:manageoverrides'] = 'Manage quiz settings overrides'; $string['quiz:viewoverrides'] = 'View quiz settings overrides'; $string['quiznavigation'] = 'Quiz navigation'; $string['quizopen'] = 'Open the quiz'; $string['quizeventopens'] = '{$a} opens'; $string['quizopenclose'] = 'Open and close dates'; $string['quizopenclose_help'] = 'Students can only start their attempt(s) after the open time and they must complete their attempts before the close time.'; $string['quizopenclose_link'] = 'mod/quiz/timing'; $string['quizopened'] = 'This quiz is open.'; $string['quizopenedon'] = 'This quiz opened on {$a}'; $string['quizopens'] = 'Quiz opens'; $string['quizopendatesoonhtml'] = '

Hi {$a->firstname},

The quiz {$a->quizname} in course {$a->coursename} is opening soon.

Opens: {$a->timeopen}

Closes: {$a->timeclose}

Go to quiz

'; $string['quizopendatesoonsubject'] = 'Opens on {$a->timeopen}: {$a->quizname}'; $string['quizopenwillclose'] = 'This quiz is open, will close on {$a} at'; $string['quizordernotrandom'] = 'Order of quiz not shuffled'; $string['quizorderrandom'] = '* Order of quiz is shuffled'; $string['quiz:preview'] = 'Preview quizzes'; $string['quiz:regrade'] = 'Regrade quiz attempts'; $string['quiz:reopenattempts'] = 'Reopen never submitted quiz attempts'; $string['quizreport'] = 'Quiz report'; $string['quiz:reviewmyattempts'] = 'Review your own attempts'; $string['quizsettings'] = 'Quiz settings'; $string['quizsetupnavigation'] = 'Quiz setup navigation'; $string['quiz:view'] = 'View quiz information'; $string['quiz:viewreports'] = 'View quiz reports'; $string['quiztimer'] = 'Quiz Timer'; $string['quizwillopen'] = 'This quiz will open {$a}'; $string['random'] = 'Random question'; $string['randomcatwithsubcat'] = '{$a} and subcategories'; $string['randomcreate'] = 'Create random questions'; $string['randomediting'] = 'Editing a random question'; $string['randomfaultynosubcat'] = 'Faulty question'; $string['randomfromcategory'] = 'Random question from category:'; $string['randomfromexistingcategory'] = 'Random question from an existing category'; $string['randomfromunavailabletag'] = '{$a} (unavailable)'; $string['randommodulewithsubcat'] = 'Any category of this quiz'; $string['randomnumber'] = 'Number of random questions'; $string['randomnosubcat'] = 'Questions from this category only, not its subcategories.'; $string['randomqname'] = 'Random question based on filter condition'; $string['randomqnamecat'] = 'Random ({$a->category}) based on filter condition'; $string['randomqnamecattags'] = 'Random ({$a->category}) based on filter condition with tags: {$a->tags}'; $string['randomquestion'] = 'Random question'; $string['randomqnametags'] = 'Random question based on filter condition with tags: {$a}'; $string['randomquestion_help'] = 'A random question is a way of inserting a randomly-chosen question from a specified category or by a specified tag into an activity.'; $string['randomquestiontags'] = 'Tags'; $string['randomquestiontags_help'] = 'You can restrict the selection criteria further by specifying some question tags here. The "random" questions will be selected from the questions that have all these tags.'; $string['randomquestionusinganewcategory'] = 'Random question using a new category'; $string['randomwithsubcat'] = 'Questions from this category and its subcategories.'; $string['readytosend'] = 'You are about to send your whole quiz to be graded. Are you sure you want to continue?'; $string['reattemptquiz'] = 'Re-attempt quiz'; $string['recentlyaddedquestion'] = 'Recently added question!'; $string['recurse'] = 'Include questions from subcategories too'; $string['redoquestion'] = 'Try another question like this one'; $string['redoesofthisquestion'] = 'Other questions attempted here: {$a}'; $string['regrade'] = 'Regrade all attempts'; $string['regradecomplete'] = 'All attempts have been regraded'; $string['regradecount'] = '{$a->changed} out of {$a->attempt} grades were changed'; $string['regradedisplayexplanation'] = 'Attempts that change during regrading are displayed as hyperlinks to the question review window'; $string['regradenotallowed'] = 'You do not have permission to regrade this quiz'; $string['regradingquestion'] = 'Regrading "{$a}".'; $string['regradingquiz'] = 'Regrading quiz "{$a}"'; $string['remove'] = 'Remove'; $string['removeallgroupoverrides'] = 'All group overrides'; $string['removeallquizattempts'] = 'All quiz attempts'; $string['removealluseroverrides'] = 'All user overrides'; $string['removeemptypage'] = 'Remove empty page'; $string['removepagebreak'] = 'Remove page break'; $string['removeselected'] = 'Remove selected'; $string['rename'] = 'Rename'; $string['renderingserverconnectfailed'] = 'The server {$a} failed to process an RQP request. Check that the URL is correct.'; $string['reopenattempt'] = 'Reopen'; $string['reopenattemptareyousuremessage'] = 'This will reopen attempt {$a->attemptnumber} by {$a->attemptuser}.'; $string['reopenattemptareyousuretitle'] = 'Reopen attempt?'; $string['reopenattemptwrongstate'] = 'Attempt {$a->attemptid} is in the wrong state ({$a->state}) to be reopened.'; $string['reopenedattemptwillbeinprogress'] = 'The attempt will remain open and can be continued.'; $string['reopenedattemptwillbeinprogressuntil'] = 'The attempt will remain open and can be continued until the quiz closes on {$a}.'; $string['reopenedattemptwillbesubmitted'] = 'The attempt will be immediately submitted for grading.'; $string['reorderquestions'] = 'Reorder questions'; $string['reordertool'] = 'Show the reordering tool'; $string['repaginate'] = 'Repaginate with {$a} questions per page'; $string['repaginatecommand'] = 'Repaginate'; $string['repaginatenow'] = 'Repaginate now'; $string['replace'] = 'Replace'; $string['replacementoptions'] = 'Replacement options'; $string['report'] = 'Reports'; $string['reportanalysis'] = 'Item analysis'; $string['reportattemptsfrom'] = 'Attempts from'; $string['reportattemptsthatare'] = 'Attempts that are'; $string['reportdisplayoptions'] = 'Display options'; $string['reportfullstat'] = 'Detailed statistics'; $string['reportmulti_percent'] = 'Multi-percentages'; $string['reportmulti_q_x_student'] = 'Multi-student choices'; $string['reportmulti_resp'] = 'Individual responses'; $string['reportmustselectstate'] = 'You must select at least one state.'; $string['reportnotfound'] = 'Report not known ({$a})'; $string['reportoverview'] = 'Overview'; $string['reportregrade'] = 'Regrade attempts'; $string['reportresponses'] = 'Detailed responses'; $string['reports'] = 'Reports'; $string['reportshowonly'] = 'Show only attempts'; $string['reportshowonlyfinished'] = 'Show at most one finished attempt per user ({$a})'; $string['reportsimplestat'] = 'Simple statistics'; $string['reportusersall'] = 'all users who have a quiz attempt'; $string['reportuserswith'] = 'enrolled users who have a quiz attempt'; $string['reportuserswithorwithout'] = 'enrolled users who have, or do not have, a quiz attempt'; $string['reportuserswithout'] = 'enrolled users who do not have a quiz attempt'; $string['reportwhattoinclude'] = 'What to include in the report'; $string['requirepassword'] = 'Require password'; $string['requirepassword_help'] = 'If a password is specified, a student must enter it in order to attempt the quiz.'; $string['requiresubnet'] = 'Require network address'; $string['requiresubnet_help'] = 'Quiz access may be restricted to particular subnets on the LAN or Internet by specifying a comma-separated list of partial or full IP address numbers. This can be useful for an invigilated (proctored) quiz, to ensure that only people in a certain location can access the quiz.'; $string['response'] = 'Response'; $string['responses'] = 'Responses'; $string['results'] = 'Results'; $string['returnattempt'] = 'Return to attempt'; $string['reuseifpossible'] = 'reuse previously removed'; $string['reverttodefaults'] = 'Revert to quiz defaults'; $string['review'] = 'Review'; $string['reviewafter'] = 'Allow review after quiz is closed'; $string['reviewalways'] = 'Allow review at any time'; $string['reviewattempt'] = 'Review attempt'; $string['reviewbefore'] = 'Allow review while quiz is open'; $string['reviewclosed'] = 'After the quiz is closed'; $string['reviewduring'] = 'During the attempt'; $string['reviewimmediately'] = 'Immediately after the attempt'; $string['reviewnever'] = 'Never allow review'; $string['reviewofquestion'] = 'Review of question {$a->question} in {$a->quiz} by {$a->user}'; $string['reviewopen'] = 'Later, while the quiz is still open'; $string['reviewoptions'] = 'Students may review'; $string['reviewoptionsheading'] = 'Review options'; $string['reviewoptionsheading_help'] = 'These options control what information students can see when they review a quiz attempt or look at the quiz reports. **During the attempt** settings are only relevant for some behaviours, like \'interactive with multiple tries\', which may display feedback during the attempt. **Immediately after the attempt** settings apply for the first two minutes after \'Submit all and finish\' is clicked. **Later, while the quiz is still open** settings apply after this, and before the quiz close date. **After the quiz is closed** settings apply after the quiz close date has passed. If the quiz does not have a close date, this state is never reached.'; $string['reviewoverallfeedback'] = 'Overall feedback'; $string['reviewoverallfeedback_help'] = 'The feedback given at the end of the attempt, depending on the student\'s total mark.'; $string['reviewresponse'] = 'Review response'; $string['reviewresponsetoq'] = 'Review response (question {$a})'; $string['reviewthisattempt'] = 'Review your responses to this attempt'; $string['rqp'] = 'Remote question'; $string['rqps'] = 'Remote questions'; $string['sameasoverall'] = 'Same as for overall grades'; $string['save'] = 'Save'; $string['saveandedit'] = 'Save changes and edit questions'; $string['saveattemptfailed'] = 'Failed to save the current quiz attempt.'; $string['savedfromdeletedcourse'] = 'Saved from deleted course "{$a}"'; $string['savegrades'] = 'Save grades'; $string['savemanualgradingfailed'] = 'Modification not saved. Please check the message below and try again.'; $string['savemyanswers'] = 'Save my answers'; $string['savenosubmit'] = 'Save without submitting'; $string['saveoverrideandstay'] = 'Save and enter another override'; $string['savequiz'] = 'Save this whole quiz'; $string['saving'] = 'Saving'; $string['savingnewgradeforquestion'] = 'Saving new grade for question id {$a}.'; $string['savingnewmaximumgrade'] = 'Saving new maximum grade.'; $string['score'] = 'Raw score'; $string['scores'] = 'Scores'; $string['search:activity'] = 'Quiz - activity information'; $string['searchbyname'] = 'Search by name...'; $string['sectionheadingedit'] = 'Edit heading \'{$a}\''; $string['sectionheadingremove'] = 'Remove heading \'{$a}\''; $string['sectionnoname'] = 'Untitled section'; $string['seequestions'] = '(See questions)'; $string['select'] = 'Select'; $string['selectall'] = 'Select all'; $string['selectattempt'] = 'Select attempt'; $string['selectcategory'] = 'Select category'; $string['selectedattempts'] = 'Selected attempts...'; $string['selectmultipleitems'] = 'Select multiple items'; $string['selectmultipletoolbar'] = 'Select multiple toolbar'; $string['selectnone'] = 'Deselect all'; $string['selectquestionbank'] = 'Select question bank'; $string['selectquestionslot'] = 'Select question {$a}'; $string['selectquestiontype'] = '-- Select question type --'; $string['sendnotificationopendatesoon'] = 'Notify user of an approaching quiz open date'; $string['serveradded'] = 'Server added'; $string['serveridentifier'] = 'Identifier'; $string['serverinfo'] = 'Server information'; $string['servers'] = 'Servers'; $string['serverurl'] = 'Server URL'; $string['shortanswer'] = 'Short answer'; $string['show'] = 'Show'; $string['showall'] = 'Show all questions on one page'; $string['showblocks'] = 'Show blocks during quiz attempts'; $string['showblocks_help'] = 'If set to yes then normal blocks will be shown during quiz attempts'; $string['showbreaks'] = 'Show page breaks'; $string['showcategorycontents'] = 'Show category contents {$a->arrow}'; $string['showcorrectanswer'] = 'In feedback, show correct answers?'; $string['showdetailedmarks'] = 'Show mark details'; $string['showeachpage'] = 'Show one page at a time'; $string['showfeedback'] = 'After answering, show feedback?'; $string['showinsecurepopup'] = 'Use a \'secure\' popup window for attempts'; $string['showlargeimage'] = 'Large image'; $string['shownoattempts'] = 'Show students with no attempts'; $string['shownoattemptsonly'] = 'Show only students with no attempts'; $string['shownoimage'] = 'No image'; $string['showreport'] = 'Show report'; $string['showsmallimage'] = 'Small image'; $string['showteacherattempts'] = 'Show teacher attempts'; $string['showuserpicture'] = 'Show the user\'s picture'; $string['showuserpicture_help'] = 'If enabled, a student\'s name and picture will be shown on-screen during the attempt, and on the review screen, making it easier to check that the student is logged in as themselves in an invigilated (proctored) exam.'; $string['shuffle'] = 'Shuffle'; $string['shuffleanswers'] = 'Shuffle answers'; $string['shuffledrandomly'] = 'Shuffled randomly'; $string['shufflequestions'] = 'Shuffle'; $string['shufflequestions_help'] = 'If enabled, every time the quiz is attempted, the order of the questions in this section will be shuffled into a different random order. This can make it harder for students to share answers, but it also makes it harder for students to discuss a particular question with the teacher.'; $string['shufflewithin'] = 'Shuffle within questions'; $string['shufflewithin_help'] = 'If enabled, the parts making up each question will be randomly shuffled each time a student attempts the quiz, provided the option is also enabled in the question settings. This setting only applies to questions that have multiple parts, such as multiple choice or matching questions.'; $string['singleanswer'] = 'Choose one answer.'; $string['sortage'] = 'Sort by age'; $string['sortalpha'] = 'Sort by name'; $string['sortquestionsbyx'] = 'Sort questions by: {$a}'; $string['sortsubmit'] = 'Sort questions'; $string['sorttypealpha'] = 'Sort by type, name'; $string['specificapathnotonquestion'] = 'The specified file path is not on the specified question'; $string['specificquestionnotonquiz'] = 'Specified question is not on the specified quiz'; $string['startagain'] = 'Start again'; $string['startattempt'] = 'Start attempt'; $string['startedon'] = 'Started'; $string['startnewpreview'] = 'Start a new preview'; $string['stateabandoned'] = 'Never submitted'; $string['statefinished'] = 'Finished'; $string['statefinisheddetails'] = 'Submitted {$a}'; $string['stateinprogress'] = 'In progress'; $string['statenotloaded'] = 'The state for question {$a} has not been loaded from the database'; $string['statenotstarted'] = 'Not started'; $string['stateoptions'] = 'Attempt state options'; $string['stateoptions_help'] = '* Not started: The attempt was automatically created before the quiz opened. The student has not started the attempt yet. * In progress: The student has started the attempt. They still have time to submit it. * Overdue: The attempt has been open for longer than the allowed time limit. The student can still submit it within the grace period. * Submitted: The student has completed the attempt and submitted their responses. It is queued for automatic marking. * Finished: The attempt has been submitted and any automatic marking is complete. * Never submitted: The student started the attempt, but did not submit it within the time limit or grace period.'; $string['stateoverdue'] = 'Overdue'; $string['stateoverduedetails'] = 'Must be submitted by {$a}'; $string['statesubmitted'] = 'Submitted'; $string['status'] = 'Status'; $string['stoponerror'] = 'Stop on error'; $string['submission_confirmation'] = 'Submit all your answers and finish?'; $string['submission_confirmation_unanswered'] = 'Questions without a response: {$a}'; $string['submitallandfinish'] = 'Submit all and finish'; $string['subneterror'] = 'Sorry, this quiz has been locked so that it is only accessible from certain locations. Currently your computer is not one of those allowed to use this quiz.'; $string['subnetnotice'] = 'This quiz has been locked so that it is only accessible from certain locations. Your computer is not on an allowed subnet. As teacher you are allowed to preview anyway.'; $string['subplugintype_quiz'] = 'Report'; $string['subplugintype_quiz_plural'] = 'Reports'; $string['subplugintype_quizaccess'] = 'Access rule'; $string['subplugintype_quizaccess_plural'] = 'Access rules'; $string['substitutedby'] = 'will be substituted by'; $string['summaryofattempt'] = 'Summary of attempt'; $string['summaryofattempts'] = 'Your attempts'; $string['summaryofattemptscaption'] = 'Attempt {$a} summary'; $string['temporaryblocked'] = 'You are temporarily not allowed to re-attempt the quiz.
You will be able to take another attempt on:'; $string['theattempt'] = 'The attempt'; $string['theattempt_help'] = 'Whether the student can review the attempt at all.'; $string['time'] = 'Time'; $string['timecompleted'] = 'Completed'; $string['timedelay'] = 'You are not allowed to do the quiz since you have not passed the time delay before attempting another quiz'; $string['timeleft'] = 'Time left'; $string['timelimit'] = 'Time limit'; $string['timelimit_help'] = 'If enabled, the time limit is stated on the initial quiz page and a countdown timer is displayed in the quiz navigation block.'; $string['timelimit_link'] = 'mod/quiz/timing'; $string['timelimitexeeded'] = 'Sorry! Quiz time limit exceeded!'; $string['timestr'] = '%H:%M:%S on %d/%m/%y'; $string['timesup'] = 'Time is up!'; $string['timing'] = 'Timing'; $string['tofile'] = 'to file'; $string['tolerance'] = 'Tolerance'; $string['toomanyrandom'] = 'The number of random questions required is more than are still available in the category!'; $string['top'] = 'Top'; $string['totalmarks'] = 'Total of marks'; $string['totalmarksx'] = 'Total of marks: {$a}'; $string['totalquestionsinrandomqcategory'] = 'Total of {$a} questions in category.'; $string['true'] = 'True'; $string['truefalse'] = 'True/false'; $string['type'] = 'Type'; $string['unfinished'] = 'open'; $string['ungraded'] = 'Ungraded'; $string['unit'] = 'Unit'; $string['unknowntype'] = 'Question type not supported at line {$a}. The question will be ignored'; $string['updatefilterconditon'] = 'Update filter conditions'; $string['updatefilterconditon_success'] = 'Successfully updated filter conditions'; $string['updateoverdueattemptstask'] = 'Updating overdue quiz attempts'; $string['updatesettings'] = 'Update quiz settings'; $string['updatequizslotswithrandomxofy'] = 'Updating quiz slots with "random" question data ({$a->done}/{$a->total})'; $string['updatingatttemptgrades'] = 'Updating attempt grades.'; $string['updatingfinalgrades'] = 'Updating final grades.'; $string['updatingthegradebook'] = 'Updating the gradebook.'; $string['upgradesure'] = '
In particular the quiz module will perform an extensive change of the quiz tables and this upgrade has not yet been sufficiently tested. You are very strongly urged to backup your database tables before proceeding.
'; $string['upgradingquizattempts'] = 'Upgrading quiz attempts: quiz {$a->done}/{$a->outof} (Quiz id {$a->info})'; $string['upgradingveryoldquizattempts'] = 'Upgrading very old quiz attempts: {$a->done}/{$a->outof}'; $string['url'] = 'URL'; $string['usedcategorymoved'] = 'This category has been preserved and moved to the site level because it is a published category still in use by other courses.'; $string['useroverrides'] = 'User overrides'; $string['usersnone'] = 'There are no users enrolled in this course or no users can access the quiz.'; $string['validate'] = 'Validate'; $string['viewallanswers'] = 'View {$a} quiz attempts'; $string['viewallreports'] = 'View reports for {$a} attempts'; $string['viewed'] = 'Viewed'; $string['warningmissingtype'] = 'This question is of a type that has not been installed on your Moodle yet.
Please alert your Moodle administrator.
'; $string['wheregrade'] = 'Where\'s my grade?'; $string['wildcard'] = 'Wild card'; $string['windowclosing'] = 'This window will close shortly.'; $string['withsummary'] = 'with summary statistics'; $string['wronguse'] = 'You can not use this page like that'; $string['xhtml'] = 'XHTML'; $string['youneedtoenrol'] = 'You need to enrol in this course before you can attempt this quiz'; $string['yourfinalgradeis'] = 'Your final grade for this quiz is {$a}.'; $string['questionversion'] = 'v{$a}'; $string['questionversionlatest'] = 'v{$a} (latest)'; $string['alwayslatest'] = 'Always latest'; $string['gobacktoquiz'] = 'Go back'; // Deprecated since Moodle 4.5. $string['attemptsdeleted'] = 'Quiz attempts deleted'; $string['gradesdeleted'] = 'Quiz grades deleted'; $string['useroverridesdeleted'] = 'User overrides deleted'; $string['groupoverridesdeleted'] = 'Group overrides deleted'; // Deprecated since Moodle 5.0. $string['randomcoursecatwithsubcat'] = 'Any category inside course category {$a}'; $string['randomcoursewithsubcat'] = 'Any category in this course'; $string['randomsystemwithsubcat'] = 'Any system-level category'; PK9B\Tlang/en/deprecated.txtnu[attemptsdeleted,mod_quiz gradesdeleted,mod_quiz useroverridesdeleted,mod_quiz groupoverridesdeleted,mod_quiz randomcoursecatwithsubcat,mod_quiz randomcoursewithsubcat,mod_quiz randomsystemwithsubcat,mod_quiz PK9B\Pp grade.phpnu[. /** * This page is the entry page into the quiz UI. Displays information about the * quiz to students and teachers, and lets students see their previous attempts. * * @package mod_quiz * @category grade * @copyright 1999 onwards Martin Dougiamas {@link http://moodle.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ use mod_quiz\quiz_attempt; use mod_quiz\quiz_settings; require_once(__DIR__ . '/../../config.php'); require_once($CFG->dirroot . '/mod/quiz/locallib.php'); require_once($CFG->dirroot . '/mod/quiz/report/reportlib.php'); $id = required_param('id', PARAM_INT); $userid = optional_param('userid', 0, PARAM_INT); $quizobj = quiz_settings::create_for_cmid($id); $quiz = $quizobj->get_quiz(); $cm = $quizobj->get_cm(); $course = $quizobj->get_course(); require_login($course, false, $cm); $reportlist = quiz_report_list($quizobj->get_context()); if (empty($reportlist) || $userid == $USER->id) { // If the user cannot see reports, or can see reports but is looking // at their own grades, redirect them to the view.php page. // (The looking at their own grades case is unlikely, since users who // appear in the gradebook are unlikely to be able to see quiz reports, // but it is possible.) redirect(new moodle_url('/mod/quiz/view.php', ['id' => $cm->id])); } // Now we know the user is interested in reports. If they are interested in a // specific other user, try to send them to the most appropriate attempt review page. if ($userid) { // Work out which attempt is most significant from a grading point of view. $attempts = quiz_get_user_attempts($quiz->id, $userid, 'finished'); $attempt = null; switch ($quiz->grademethod) { case QUIZ_ATTEMPTFIRST: $attempt = reset($attempts); break; case QUIZ_ATTEMPTLAST: case QUIZ_GRADEAVERAGE: $attempt = end($attempts); break; case QUIZ_GRADEHIGHEST: $maxmark = 0; foreach ($attempts as $at) { // Operator >=, since we want to most recent relevant attempt. if ((float) $at->sumgrades >= $maxmark) { $maxmark = $at->sumgrades; $attempt = $at; } } break; } // If the user can review the relevant attempt, redirect to it. if ($attempt) { $attemptobj = new quiz_attempt($attempt, $quiz, $cm, $course, false); if ($attemptobj->is_review_allowed()) { $attemptobj->load_questions(); redirect($attemptobj->review_url()); } } // Otherwise, fall thorugh to the generic case. } // Send the user to the first report they can see. redirect(new moodle_url('/mod/quiz/report.php', [ 'id' => $cm->id, 'mode' => reset($reportlist)])); PK9B\o|44accessmanager.phpnu[. /** * File only retained to prevent fatal errors in code that tries to require/include this. * * @todo MDL-76612 delete this file as part of Moodle 4.6 development. * @deprecated This file is no longer required in Moodle 4.2+. */ defined('MOODLE_INTERNAL') || die(); debugging('This file is no longer required in Moodle 4.2+. Please do not include/require it.', DEBUG_DEVELOPER); PK9B\ 2backup/moodle2/backup_quiz_activity_task.class.phpnu[. /** * Defines backup_quiz_activity_task class * * @package mod_quiz * @category backup * @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 . '/mod/quiz/backup/moodle2/backup_quiz_stepslib.php'); /** * Provides the steps to perform one complete backup of the Quiz instance * * @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_quiz_activity_task extends backup_activity_task { /** * No specific settings for this activity */ protected function define_my_settings() { } /** * Defines backup steps to store the instance data and required questions */ protected function define_my_steps() { // Generate the quiz.xml file containing all the quiz information // and annotating used questions. $this->add_step(new backup_quiz_activity_structure_step('quiz_structure', 'quiz.xml')); // Note: Following steps must be present // in all the activities using question banks (only quiz for now) // TODO: Specialise these step to a new subclass of backup_activity_task. // Process all the annotated questions to calculate the question // categories needing to be included in backup for this activity // plus the categories belonging to the activity context itself. $this->add_step(new backup_calculate_question_categories('activity_question_categories')); // Clean backup_temp_ids table from questions. We already // have used them to detect question_categories and aren't // needed anymore. $this->add_step(new backup_delete_temp_questions('clean_temp_questions')); } /** * Encodes URLs to the index.php and view.php scripts * * @param string $content some HTML text that eventually contains URLs to the activity instance scripts * @return string the content with the URLs encoded */ public static function encode_content_links($content) { global $CFG; $base = preg_quote($CFG->wwwroot, '/'); // Link to the list of quizzes. $search="/(".$base."\/mod\/quiz\/index.php\?id\=)([0-9]+)/"; $content= preg_replace($search, '$@QUIZINDEX*$2@$', $content); // Link to quiz view by moduleid. $search="/(".$base."\/mod\/quiz\/view.php\?id\=)([0-9]+)/"; $content= preg_replace($search, '$@QUIZVIEWBYID*$2@$', $content); // Link to quiz view by quizid. $search="/(".$base."\/mod\/quiz\/view.php\?q\=)([0-9]+)/"; $content= preg_replace($search, '$@QUIZVIEWBYQ*$2@$', $content); return $content; } } PK9B\{//9backup/moodle2/backup_mod_quiz_access_subplugin.class.phpnu[. /** * Defines the base class for quiz access plugins backup code. * * @package mod_quiz * @copyright 2011 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); /** * Base class for backing up all the quiz settings and attempt data for an * access rule quiz sub-plugin. * * @copyright 2011 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class backup_mod_quiz_access_subplugin extends backup_subplugin { /** * Use this method to describe the XML structure required to store your * sub-plugin's settings for a particular quiz, and how that data is stored * in the database. */ protected function define_quiz_subplugin_structure() { // Do nothing by default. } /** * Use this method to describe the XML structure required to store your * sub-plugin's settings for a particular quiz attempt, and how that data * is stored in the database. */ protected function define_attempt_subplugin_structure() { // Do nothing by default. } } PK9B\2p'backup/moodle2/backup_quiz_stepslib.phpnu[. /** * Define all the backup steps that will be used by the backup_quiz_activity_task. * * @package mod_quiz * @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 */ class backup_quiz_activity_structure_step extends backup_questions_activity_structure_step { protected function define_structure() { // To know if we are including userinfo. $userinfo = $this->get_setting_value('userinfo'); // Define each element separated. $quiz = new backup_nested_element('quiz', ['id'], [ 'name', 'intro', 'introformat', 'timeopen', 'timeclose', 'timelimit', 'overduehandling', 'graceperiod', 'preferredbehaviour', 'canredoquestions', 'attempts_number', 'attemptonlast', 'grademethod', 'decimalpoints', 'questiondecimalpoints', 'reviewattempt', 'reviewcorrectness', 'reviewmaxmarks', 'reviewmarks', 'reviewspecificfeedback', 'reviewgeneralfeedback', 'reviewrightanswer', 'reviewoverallfeedback', 'questionsperpage', 'navmethod', 'shuffleanswers', 'sumgrades', 'grade', 'timecreated', 'timemodified', 'password', 'subnet', 'browsersecurity', 'delay1', 'delay2', 'showuserpicture', 'showblocks', 'completionattemptsexhausted', 'completionminattempts', 'allowofflineattempts', 'precreateattempts']); // Define elements for access rule subplugin settings. $this->add_subplugin_structure('quizaccess', $quiz, true); $quizgradeitems = new backup_nested_element('quiz_grade_items'); $quizgradeitem = new backup_nested_element('quiz_grade_item', ['id'], ['sortorder', 'name']); $qinstances = new backup_nested_element('question_instances'); $qinstance = new backup_nested_element('question_instance', ['id'], ['quizid', 'slot', 'page', 'displaynumber', 'requireprevious', 'maxmark', 'quizgradeitemid']); $this->add_question_references($qinstance, 'mod_quiz', 'slot'); $this->add_question_set_references($qinstance, 'mod_quiz', 'slot'); $this->annotate_set_reference_bank_entries($this->task->get_contextid(), 'mod_quiz', 'slot', $this->task->get_backupid()); $sections = new backup_nested_element('sections'); $section = new backup_nested_element('section', ['id'], ['firstslot', 'heading', 'shufflequestions']); $feedbacks = new backup_nested_element('feedbacks'); $feedback = new backup_nested_element('feedback', ['id'], ['feedbacktext', 'feedbacktextformat', 'mingrade', 'maxgrade']); $overrides = new backup_nested_element('overrides'); $override = new backup_nested_element('override', ['id'], [ 'userid', 'groupid', 'timeopen', 'timeclose', 'timelimit', 'attempts', 'password']); $grades = new backup_nested_element('grades'); $grade = new backup_nested_element('grade', ['id'], ['userid', 'gradeval', 'timemodified']); $attempts = new backup_nested_element('attempts'); $attempt = new backup_nested_element('attempt', ['id'], [ 'userid', 'attemptnum', 'uniqueid', 'layout', 'currentpage', 'preview', 'state', 'timestart', 'timefinish', 'timemodified', 'timemodifiedoffline', 'timecheckstate', 'sumgrades', 'gradednotificationsenttime']); // This module is using questions, so produce the related question states and sessions // attaching them to the $attempt element based in 'uniqueid' matching. $this->add_question_usages($attempt, 'uniqueid'); // Define elements for access rule subplugin attempt data. $this->add_subplugin_structure('quizaccess', $attempt, true); // Build the tree. $quiz->add_child($quizgradeitems); $quizgradeitems->add_child($quizgradeitem); $quiz->add_child($qinstances); $qinstances->add_child($qinstance); $quiz->add_child($sections); $sections->add_child($section); $quiz->add_child($feedbacks); $feedbacks->add_child($feedback); $quiz->add_child($overrides); $overrides->add_child($override); $quiz->add_child($grades); $grades->add_child($grade); $quiz->add_child($attempts); $attempts->add_child($attempt); // Define sources. $quiz->set_source_table('quiz', ['id' => backup::VAR_ACTIVITYID]); $quizgradeitem->set_source_table('quiz_grade_items', ['quizid' => backup::VAR_PARENTID]); $qinstance->set_source_table('quiz_slots', ['quizid' => backup::VAR_PARENTID]); $section->set_source_table('quiz_sections', ['quizid' => backup::VAR_PARENTID]); $feedback->set_source_table('quiz_feedback', ['quizid' => backup::VAR_PARENTID]); // Quiz overrides to backup are different depending of user info. $overrideparams = ['quiz' => backup::VAR_PARENTID]; if (!$userinfo) { // Without userinfo, skip user overrides. $overrideparams['userid'] = backup_helper::is_sqlparam(null); } // Skip group overrides if not including groups. $groupinfo = $this->get_setting_value('groups'); if (!$groupinfo) { $overrideparams['groupid'] = backup_helper::is_sqlparam(null); } $override->set_source_table('quiz_overrides', $overrideparams); // All the rest of elements only happen if we are including user info. if ($userinfo) { $grade->set_source_table('quiz_grades', ['quiz' => backup::VAR_PARENTID]); $attempt->set_source_sql(' SELECT * FROM {quiz_attempts} WHERE quiz = :quiz AND preview = 0', ['quiz' => backup::VAR_PARENTID]); } // Define source alias. $quiz->set_source_alias('attempts', 'attempts_number'); $grade->set_source_alias('grade', 'gradeval'); $attempt->set_source_alias('attempt', 'attemptnum'); // Define id annotations. $override->annotate_ids('user', 'userid'); $override->annotate_ids('group', 'groupid'); $grade->annotate_ids('user', 'userid'); $attempt->annotate_ids('user', 'userid'); // Define file annotations. $quiz->annotate_files('mod_quiz', 'intro', null); // This file area hasn't itemid. $feedback->annotate_files('mod_quiz', 'feedback', 'id'); // Return the root element (quiz), wrapped into standard activity structure. return $this->prepare_activity_structure($quiz); } } PK9B\ÊLo!o!3backup/moodle2/restore_quiz_activity_task.class.phpnu[. /** * @package mod_quiz * @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 . '/mod/quiz/backup/moodle2/restore_quiz_stepslib.php'); /** * quiz restore task that provides all the settings and steps to perform one * complete restore of the activity * * @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_quiz_activity_task extends restore_activity_task { /** * Define (add) particular settings this activity can have */ protected function define_my_settings() { // No particular settings for this activity. } /** * Define (add) particular steps this activity can have */ protected function define_my_steps() { // Quiz only has one structure step. $this->add_step(new restore_quiz_activity_structure_step('quiz_structure', 'quiz.xml')); } /** * Define the contents in the activity that must be * processed by the link decoder */ public static function define_decode_contents() { $contents = []; $contents[] = new restore_decode_content('quiz', ['intro'], 'quiz'); $contents[] = new restore_decode_content('quiz_feedback', ['feedbacktext'], 'quiz_feedback'); return $contents; } /** * Define the decoding rules for links belonging * to the activity to be executed by the link decoder */ public static function define_decode_rules() { $rules = []; $rules[] = new restore_decode_rule('QUIZVIEWBYID', '/mod/quiz/view.php?id=$1', 'course_module'); $rules[] = new restore_decode_rule('QUIZVIEWBYQ', '/mod/quiz/view.php?q=$1', 'quiz'); $rules[] = new restore_decode_rule('QUIZINDEX', '/mod/quiz/index.php?id=$1', 'course'); return $rules; } /** * Define the restore log rules that will be applied * by the {@link restore_logs_processor} when restoring * quiz logs. It must return one array * of {@link restore_log_rule} objects */ public static function define_restore_log_rules() { $rules = []; $rules[] = new restore_log_rule('quiz', 'add', 'view.php?id={course_module}', '{quiz}'); $rules[] = new restore_log_rule('quiz', 'update', 'view.php?id={course_module}', '{quiz}'); $rules[] = new restore_log_rule('quiz', 'view', 'view.php?id={course_module}', '{quiz}'); $rules[] = new restore_log_rule('quiz', 'preview', 'view.php?id={course_module}', '{quiz}'); $rules[] = new restore_log_rule('quiz', 'report', 'report.php?id={course_module}', '{quiz}'); $rules[] = new restore_log_rule('quiz', 'editquestions', 'view.php?id={course_module}', '{quiz}'); $rules[] = new restore_log_rule('quiz', 'delete attempt', 'report.php?id={course_module}', '[oldattempt]'); $rules[] = new restore_log_rule('quiz', 'edit override', 'overrideedit.php?id={quiz_override}', '{quiz}'); $rules[] = new restore_log_rule('quiz', 'delete override', 'overrides.php.php?cmid={course_module}', '{quiz}'); $rules[] = new restore_log_rule('quiz', 'addcategory', 'view.php?id={course_module}', '{question_category}'); $rules[] = new restore_log_rule('quiz', 'view summary', 'summary.php?attempt={quiz_attempt}', '{quiz}'); $rules[] = new restore_log_rule('quiz', 'manualgrade', 'comment.php?attempt={quiz_attempt}&question={question}', '{quiz}'); $rules[] = new restore_log_rule('quiz', 'manualgrading', 'report.php?mode=grading&q={quiz}', '{quiz}'); // All the ones calling to review.php have two rules to handle both old and new urls // in any case they are always converted to new urls on restore. // TODO: In Moodle 2.x (x >= 5) kill the old rules. // Note we are using the 'quiz_attempt' mapping because that is the // one containing the quiz_attempt->ids old an new for quiz-attempt. $rules[] = new restore_log_rule('quiz', 'attempt', 'review.php?id={course_module}&attempt={quiz_attempt}', '{quiz}', null, null, 'review.php?attempt={quiz_attempt}'); $rules[] = new restore_log_rule('quiz', 'attempt', 'review.php?attempt={quiz_attempt}', '{quiz}', null, null, 'review.php?attempt={quiz_attempt}'); // Old an new for quiz-submit. $rules[] = new restore_log_rule('quiz', 'submit', 'review.php?id={course_module}&attempt={quiz_attempt}', '{quiz}', null, null, 'review.php?attempt={quiz_attempt}'); $rules[] = new restore_log_rule('quiz', 'submit', 'review.php?attempt={quiz_attempt}', '{quiz}'); // Old an new for quiz-review. $rules[] = new restore_log_rule('quiz', 'review', 'review.php?id={course_module}&attempt={quiz_attempt}', '{quiz}', null, null, 'review.php?attempt={quiz_attempt}'); $rules[] = new restore_log_rule('quiz', 'review', 'review.php?attempt={quiz_attempt}', '{quiz}'); // Old an new for quiz-start attemp. $rules[] = new restore_log_rule('quiz', 'start attempt', 'review.php?id={course_module}&attempt={quiz_attempt}', '{quiz}', null, null, 'review.php?attempt={quiz_attempt}'); $rules[] = new restore_log_rule('quiz', 'start attempt', 'review.php?attempt={quiz_attempt}', '{quiz}'); // Old an new for quiz-close attemp. $rules[] = new restore_log_rule('quiz', 'close attempt', 'review.php?id={course_module}&attempt={quiz_attempt}', '{quiz}', null, null, 'review.php?attempt={quiz_attempt}'); $rules[] = new restore_log_rule('quiz', 'close attempt', 'review.php?attempt={quiz_attempt}', '{quiz}'); // Old an new for quiz-continue attempt. $rules[] = new restore_log_rule('quiz', 'continue attempt', 'review.php?id={course_module}&attempt={quiz_attempt}', '{quiz}', null, null, 'review.php?attempt={quiz_attempt}'); $rules[] = new restore_log_rule('quiz', 'continue attempt', 'review.php?attempt={quiz_attempt}', '{quiz}'); // Old an new for quiz-continue attemp. $rules[] = new restore_log_rule('quiz', 'continue attemp', 'review.php?id={course_module}&attempt={quiz_attempt}', '{quiz}', null, 'continue attempt', 'review.php?attempt={quiz_attempt}'); $rules[] = new restore_log_rule('quiz', 'continue attemp', 'review.php?attempt={quiz_attempt}', '{quiz}', null, 'continue attempt'); return $rules; } /** * Define the restore log rules that will be applied * by the {@link restore_logs_processor} when restoring * course logs. It must return one array * of {@link restore_log_rule} objects * * Note this rules are applied when restoring course logs * by the restore final task, but are defined here at * activity level. All them are rules not linked to any module instance (cmid = 0) */ public static function define_restore_log_rules_for_course() { $rules = []; $rules[] = new restore_log_rule('quiz', 'view all', 'index.php?id={course}', null); return $rules; } } PK9B\E0:backup/moodle2/restore_mod_quiz_access_subplugin.class.phpnu[. /** * Restore code for the quizaccess_honestycheck plugin. * * @package mod_quiz * @copyright 2011 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); /** * Base class for restoring up all the quiz settings and attempt data for an * access rule quiz sub-plugin. * * @copyright 2011 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class restore_mod_quiz_access_subplugin extends restore_subplugin { /** * Use this method to describe the XML paths that store your sub-plugin's * settings for a particular quiz. */ protected function define_quiz_subplugin_structure() { // Do nothing by default. } /** * Use this method to describe the XML paths that store your sub-plugin's * settings for a particular quiz attempt. */ protected function define_attempt_subplugin_structure() { // Do nothing by default. } } PK9B\/ș:l:l(backup/moodle2/restore_quiz_stepslib.phpnu[. use mod_quiz\question\display_options; /** * Structure step to restore one quiz activity * * @package mod_quiz * @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 */ class restore_quiz_activity_structure_step extends restore_questions_activity_structure_step { /** * @var bool tracks whether the quiz contains at least one section. Before * Moodle 2.9 quiz sections did not exist, so if the file being restored * did not contain any, we need to create one in {@link after_execute()}. */ protected $sectioncreated = false; /** @var stdClass|null $currentquizattempt Track the current quiz attempt being restored. */ protected $currentquizattempt = null; /** * @var bool when restoring old quizzes (2.8 or before) this records the * shufflequestionsoption quiz option which has moved to the quiz_sections table. */ protected $legacyshufflequestionsoption = false; /** @var stdClass */ protected $oldquizlayout; /** * @var array Track old question ids that need to be removed at the end of the restore. */ protected $oldquestionids = []; protected function define_structure() { $paths = []; $userinfo = $this->get_setting_value('userinfo'); $quiz = new restore_path_element('quiz', '/activity/quiz'); $paths[] = $quiz; // A chance for access subplugings to set up their quiz data. $this->add_subplugin_structure('quizaccess', $quiz); $paths[] = new restore_path_element('quiz_grade_item', '/activity/quiz/quiz_grade_items/quiz_grade_item'); $quizquestioninstance = new restore_path_element('quiz_question_instance', '/activity/quiz/question_instances/question_instance'); $paths[] = $quizquestioninstance; if ($this->task->get_old_moduleversion() < 2021091700) { $paths[] = new restore_path_element('quiz_slot_tags', '/activity/quiz/question_instances/question_instance/tags/tag'); } else { $this->add_question_references($quizquestioninstance, $paths); $this->add_question_set_references($quizquestioninstance, $paths); } $paths[] = new restore_path_element('quiz_section', '/activity/quiz/sections/section'); $paths[] = new restore_path_element('quiz_feedback', '/activity/quiz/feedbacks/feedback'); $paths[] = new restore_path_element('quiz_override', '/activity/quiz/overrides/override'); if ($userinfo) { $paths[] = new restore_path_element('quiz_grade', '/activity/quiz/grades/grade'); if ($this->task->get_old_moduleversion() > 2011010100) { // Restoring from a version 2.1 dev or later. // Process the new-style attempt data. $quizattempt = new restore_path_element('quiz_attempt', '/activity/quiz/attempts/attempt'); $paths[] = $quizattempt; // Add states and sessions. $this->add_question_usages($quizattempt, $paths); // A chance for access subplugings to set up their attempt data. $this->add_subplugin_structure('quizaccess', $quizattempt); } else { // Restoring from a version 2.0.x+ or earlier. // Upgrade the legacy attempt data. $quizattempt = new restore_path_element('quiz_attempt_legacy', '/activity/quiz/attempts/attempt', true); $paths[] = $quizattempt; $this->add_legacy_question_attempt_data($quizattempt, $paths); } } // Return the paths wrapped into standard activity structure. return $this->prepare_activity_structure($paths); } /** * Process the quiz data. * * @param stdClass|array $data */ protected function process_quiz($data) { global $CFG, $DB, $USER; $data = (object)$data; $oldid = $data->id; $data->course = $this->get_courseid(); // Any changes to the list of dates that needs to be rolled should be same during course restore and course reset. // See MDL-9367. $data->timeopen = $this->apply_date_offset($data->timeopen); $data->timeclose = $this->apply_date_offset($data->timeclose); if (property_exists($data, 'questions')) { // Needed by {@link process_quiz_attempt_legacy}, in which case it will be present. $this->oldquizlayout = $data->questions; } // The setting quiz->attempts can come both in data->attempts and // data->attempts_number, handle both. MDL-26229. if (isset($data->attempts_number)) { $data->attempts = $data->attempts_number; unset($data->attempts_number); } // The old optionflags and penaltyscheme from 2.0 need to be mapped to // the new preferredbehaviour. See MDL-20636. if (!isset($data->preferredbehaviour)) { if (empty($data->optionflags)) { $data->preferredbehaviour = 'deferredfeedback'; } else if (empty($data->penaltyscheme)) { $data->preferredbehaviour = 'adaptivenopenalty'; } else { $data->preferredbehaviour = 'adaptive'; } unset($data->optionflags); unset($data->penaltyscheme); } // The old review column from 2.0 need to be split into the seven new // review columns. See MDL-20636. if (isset($data->review)) { require_once($CFG->dirroot . '/mod/quiz/locallib.php'); if (!defined('QUIZ_OLD_IMMEDIATELY')) { define('QUIZ_OLD_IMMEDIATELY', 0x3c003f); define('QUIZ_OLD_OPEN', 0x3c00fc0); define('QUIZ_OLD_CLOSED', 0x3c03f000); define('QUIZ_OLD_RESPONSES', 1*0x1041); define('QUIZ_OLD_SCORES', 2*0x1041); define('QUIZ_OLD_FEEDBACK', 4*0x1041); define('QUIZ_OLD_ANSWERS', 8*0x1041); define('QUIZ_OLD_SOLUTIONS', 16*0x1041); define('QUIZ_OLD_GENERALFEEDBACK', 32*0x1041); define('QUIZ_OLD_OVERALLFEEDBACK', 1*0x4440000); } $oldreview = $data->review; $data->reviewattempt = display_options::DURING | ($oldreview & QUIZ_OLD_IMMEDIATELY & QUIZ_OLD_RESPONSES ? display_options::IMMEDIATELY_AFTER : 0) | ($oldreview & QUIZ_OLD_OPEN & QUIZ_OLD_RESPONSES ? display_options::LATER_WHILE_OPEN : 0) | ($oldreview & QUIZ_OLD_CLOSED & QUIZ_OLD_RESPONSES ? display_options::AFTER_CLOSE : 0); $data->reviewcorrectness = display_options::DURING | ($oldreview & QUIZ_OLD_IMMEDIATELY & QUIZ_OLD_SCORES ? display_options::IMMEDIATELY_AFTER : 0) | ($oldreview & QUIZ_OLD_OPEN & QUIZ_OLD_SCORES ? display_options::LATER_WHILE_OPEN : 0) | ($oldreview & QUIZ_OLD_CLOSED & QUIZ_OLD_SCORES ? display_options::AFTER_CLOSE : 0); $data->reviewmarks = display_options::DURING | ($oldreview & QUIZ_OLD_IMMEDIATELY & QUIZ_OLD_SCORES ? display_options::IMMEDIATELY_AFTER : 0) | ($oldreview & QUIZ_OLD_OPEN & QUIZ_OLD_SCORES ? display_options::LATER_WHILE_OPEN : 0) | ($oldreview & QUIZ_OLD_CLOSED & QUIZ_OLD_SCORES ? display_options::AFTER_CLOSE : 0); $data->reviewspecificfeedback = ($oldreview & QUIZ_OLD_IMMEDIATELY & QUIZ_OLD_FEEDBACK ? display_options::DURING : 0) | ($oldreview & QUIZ_OLD_IMMEDIATELY & QUIZ_OLD_FEEDBACK ? display_options::IMMEDIATELY_AFTER : 0) | ($oldreview & QUIZ_OLD_OPEN & QUIZ_OLD_FEEDBACK ? display_options::LATER_WHILE_OPEN : 0) | ($oldreview & QUIZ_OLD_CLOSED & QUIZ_OLD_FEEDBACK ? display_options::AFTER_CLOSE : 0); $data->reviewgeneralfeedback = ($oldreview & QUIZ_OLD_IMMEDIATELY & QUIZ_OLD_GENERALFEEDBACK ? display_options::DURING : 0) | ($oldreview & QUIZ_OLD_IMMEDIATELY & QUIZ_OLD_GENERALFEEDBACK ? display_options::IMMEDIATELY_AFTER : 0) | ($oldreview & QUIZ_OLD_OPEN & QUIZ_OLD_GENERALFEEDBACK ? display_options::LATER_WHILE_OPEN : 0) | ($oldreview & QUIZ_OLD_CLOSED & QUIZ_OLD_GENERALFEEDBACK ? display_options::AFTER_CLOSE : 0); $data->reviewrightanswer = ($oldreview & QUIZ_OLD_IMMEDIATELY & QUIZ_OLD_ANSWERS ? display_options::DURING : 0) | ($oldreview & QUIZ_OLD_IMMEDIATELY & QUIZ_OLD_ANSWERS ? display_options::IMMEDIATELY_AFTER : 0) | ($oldreview & QUIZ_OLD_OPEN & QUIZ_OLD_ANSWERS ? display_options::LATER_WHILE_OPEN : 0) | ($oldreview & QUIZ_OLD_CLOSED & QUIZ_OLD_ANSWERS ? display_options::AFTER_CLOSE : 0); $data->reviewoverallfeedback = 0 | ($oldreview & QUIZ_OLD_IMMEDIATELY & QUIZ_OLD_OVERALLFEEDBACK ? display_options::IMMEDIATELY_AFTER : 0) | ($oldreview & QUIZ_OLD_OPEN & QUIZ_OLD_OVERALLFEEDBACK ? display_options::LATER_WHILE_OPEN : 0) | ($oldreview & QUIZ_OLD_CLOSED & QUIZ_OLD_OVERALLFEEDBACK ? display_options::AFTER_CLOSE : 0); } // New setting in 4.3 needs to be set if not in the backup. if (!isset($data->reviewmaxmarks)) { $data->reviewmaxmarks = display_options::DURING | display_options::IMMEDIATELY_AFTER | display_options::LATER_WHILE_OPEN | display_options::AFTER_CLOSE; } // The old popup column from from <= 2.1 need to be mapped to // the new browsersecurity. See MDL-29627. if (!isset($data->browsersecurity)) { if (empty($data->popup)) { $data->browsersecurity = '-'; } else if ($data->popup == 1) { $data->browsersecurity = 'securewindow'; } else if ($data->popup == 2) { // Since 3.9 quizaccess_safebrowser replaced with a new quizaccess_seb. $data->browsersecurity = '-'; $addsebrule = true; } else { $data->preferredbehaviour = '-'; } unset($data->popup); } else if ($data->browsersecurity == 'safebrowser') { // Since 3.9 quizaccess_safebrowser replaced with a new quizaccess_seb. $data->browsersecurity = '-'; $addsebrule = true; } if (!isset($data->overduehandling)) { $data->overduehandling = get_config('quiz', 'overduehandling'); } // Old shufflequestions setting is now stored in quiz sections, // so save it here if necessary so it is available when we need it. $this->legacyshufflequestionsoption = !empty($data->shufflequestions); // Insert the quiz record. $newitemid = $DB->insert_record('quiz', $data); // Immediately after inserting "activity" record, call this. $this->apply_activity_instance($newitemid); // Process Safe Exam Browser settings for backups taken in Moodle < 3.9. if (!empty($addsebrule)) { $sebsettings = new stdClass(); $sebsettings->quizid = $newitemid; $sebsettings->cmid = $this->task->get_moduleid(); $sebsettings->templateid = 0; $sebsettings->requiresafeexambrowser = \quizaccess_seb\settings_provider::USE_SEB_CLIENT_CONFIG; $sebsettings->showsebtaskbar = null; $sebsettings->showwificontrol = null; $sebsettings->showreloadbutton = null; $sebsettings->showtime = null; $sebsettings->showkeyboardlayout = null; $sebsettings->allowuserquitseb = null; $sebsettings->quitpassword = null; $sebsettings->linkquitseb = null; $sebsettings->userconfirmquit = null; $sebsettings->enableaudiocontrol = null; $sebsettings->muteonstartup = null; $sebsettings->allowcapturecamera = null; $sebsettings->allowcapturemicrophone = null; $sebsettings->allowspellchecking = null; $sebsettings->allowreloadinexam = null; $sebsettings->activateurlfiltering = null; $sebsettings->filterembeddedcontent = null; $sebsettings->expressionsallowed = null; $sebsettings->regexallowed = null; $sebsettings->expressionsblocked = null; $sebsettings->regexblocked = null; $sebsettings->allowedbrowserexamkeys = null; $sebsettings->showsebdownloadlink = 1; $sebsettings->usermodified = $USER->id; $sebsettings->timecreated = time(); $sebsettings->timemodified = time(); $DB->insert_record('quizaccess_seb_quizsettings', $sebsettings); } // If we are dealing with a backup from < 4.0 then we need to move completionpass to core. if (!empty($data->completionpass)) { $params = ['id' => $this->task->get_moduleid()]; $DB->set_field('course_modules', 'completionpassgrade', $data->completionpass, $params); } } /** * Process a quiz grade items. * * @param stdClass|array $data */ protected function process_quiz_grade_item($data): void { global $DB; $data = (object) $data; $data->quizid = $this->get_new_parentid('quiz'); $oldid = $data->id; $newitemid = $DB->insert_record('quiz_grade_items', $data); $this->set_mapping('quiz_grade_item', $oldid, $newitemid, true); } /** * Process the data for pre 4.0 quiz data where the question_references and question_set_references table introduced. * * @param stdClass|array $data */ protected function process_quiz_question_legacy_instance($data) { global $DB; $questionid = $this->get_mappingid('question', $data->questionid); $sql = 'SELECT qbe.id as questionbankentryid, qc.contextid as questioncontextid, qc.id as category, qv.version, q.qtype, q.id as questionid 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 = ?'; $question = $DB->get_record_sql($sql, [$questionid]); $module = $DB->get_record('quiz', ['id' => $data->quizid]); if ($question->qtype === 'random') { // Set reference data. $questionsetreference = new stdClass(); $questionsetreference->usingcontextid = $this->task->get_contextid(); $questionsetreference->component = 'mod_quiz'; $questionsetreference->questionarea = 'slot'; $questionsetreference->itemid = $data->id; // If, in the orginal quiz that was backed up, this random question was pointing to a // category in the quiz question bank, then (for reasons explained in {@see restore_move_module_questions_categories}) // right now, $question->questioncontextid will incorrectly point to the course contextid. // This will get fixed up later in restore_move_module_questions_categories // as part of moving the question categories to the right place. $questionsetreference->questionscontextid = $question->questioncontextid; $filtercondition = new stdClass(); $filtercondition->questioncategoryid = $question->category; $filtercondition->includingsubcategories = $data->includingsubcategories ?? false; $questionsetreference->filtercondition = json_encode($filtercondition); $DB->insert_record('question_set_references', $questionsetreference); $this->oldquestionids[$question->questionid] = 1; } else { // Reference data. $questionreference = new \stdClass(); $questionreference->usingcontextid = $this->task->get_contextid(); $questionreference->component = 'mod_quiz'; $questionreference->questionarea = 'slot'; $questionreference->itemid = $data->id; $questionreference->questionbankentryid = $question->questionbankentryid; $questionreference->version = null; // Default to Always latest. $DB->insert_record('question_references', $questionreference); } } /** * Process quiz slots. * * @param stdClass|array $data */ protected function process_quiz_question_instance($data) { global $DB; $data = (object)$data; $oldid = $data->id; // Backwards compatibility for old field names (MDL-43670). if (!isset($data->questionid) && isset($data->question)) { $data->questionid = $data->question; } if (!isset($data->maxmark) && isset($data->grade)) { $data->maxmark = $data->grade; } if (!property_exists($data, 'slot')) { $page = 1; $slot = 1; foreach (explode(',', $this->oldquizlayout) as $item) { if ($item == 0) { $page += 1; continue; } if (isset($data->questionid) && $item == $data->questionid) { $data->slot = $slot; $data->page = $page; break; } $slot += 1; } } if (!property_exists($data, 'slot')) { // There was a question_instance in the backup file for a question // that was not actually in the quiz. Drop it. $this->log('question ' . $data->questionid . ' was associated with quiz ' . $this->get_new_parentid('quiz') . ' but not actually used. ' . 'The instance has been ignored.', backup::LOG_INFO); return; } if (isset($data->quizgradeitemid)) { $data->quizgradeitemid = $this->get_mappingid('quiz_grade_item', $data->quizgradeitemid); } $data->quizid = $this->get_new_parentid('quiz'); $newitemid = $DB->insert_record('quiz_slots', $data); // Add mapping, restore of slot tags (for random questions) need it. $this->set_mapping('quiz_question_instance', $oldid, $newitemid); if ($this->task->get_old_moduleversion() < 2022020300) { $data->id = $newitemid; $this->process_quiz_question_legacy_instance($data); } } /** * Process a quiz_slot_tags to restore the tags to the new structure. * * @param stdClass|array $data The quiz_slot_tags data */ protected function process_quiz_slot_tags($data) { global $DB; $data = (object) $data; $slotid = $this->get_new_parentid('quiz_question_instance'); if ($this->task->is_samesite() && $tag = core_tag_tag::get($data->tagid, 'id, name')) { $data->tagname = $tag->name; } else if ($tag = core_tag_tag::get_by_name(0, $data->tagname, 'id, name')) { $data->tagid = $tag->id; } else { $data->tagid = null; $data->tagname = $tag->name; } $tagstring = "{$data->tagid},{$data->tagname}"; $setreferencedata = $DB->get_record('question_set_references', [ 'usingcontextid' => $this->task->get_contextid(), 'component' => 'mod_quiz', 'questionarea' => 'slot', 'itemid' => $slotid, ]); $filtercondition = json_decode($setreferencedata->filtercondition); $filtercondition->tags[] = $tagstring; $setreferencedata->filtercondition = json_encode($filtercondition); $DB->update_record('question_set_references', $setreferencedata); } protected function process_quiz_section($data) { global $DB; $data = (object) $data; $data->quizid = $this->get_new_parentid('quiz'); $oldid = $data->id; $newitemid = $DB->insert_record('quiz_sections', $data); $this->sectioncreated = true; $this->set_mapping('quiz_section', $oldid, $newitemid, true); } protected function process_quiz_feedback($data) { global $DB; $data = (object)$data; $oldid = $data->id; $data->quizid = $this->get_new_parentid('quiz'); $newitemid = $DB->insert_record('quiz_feedback', $data); $this->set_mapping('quiz_feedback', $oldid, $newitemid, true); // Has related files. } protected function process_quiz_override($data) { global $DB; $data = (object)$data; $oldid = $data->id; // Based on userinfo, we'll restore user overides or no. $userinfo = $this->get_setting_value('userinfo'); // Skip user overrides if we are not restoring userinfo. if (!$userinfo && !is_null($data->userid)) { return; } $data->quiz = $this->get_new_parentid('quiz'); if ($data->userid !== null) { $data->userid = $this->get_mappingid('user', $data->userid); } if ($data->groupid !== null) { $data->groupid = $this->get_mappingid('group', $data->groupid); } // Skip if there is no user and no group data. if (empty($data->userid) && empty($data->groupid)) { return; } $data->timeopen = $this->apply_date_offset($data->timeopen); $data->timeclose = $this->apply_date_offset($data->timeclose); $newitemid = $DB->insert_record('quiz_overrides', $data); // Add mapping, restore of logs needs it. $this->set_mapping('quiz_override', $oldid, $newitemid); } protected function process_quiz_grade($data) { global $DB; $data = (object)$data; $oldid = $data->id; $data->quiz = $this->get_new_parentid('quiz'); $data->userid = $this->get_mappingid('user', $data->userid); $data->grade = $data->gradeval; $DB->insert_record('quiz_grades', $data); } protected function process_quiz_attempt($data) { $data = (object)$data; $data->quiz = $this->get_new_parentid('quiz'); $data->attempt = $data->attemptnum; // Get user mapping, return early if no mapping found for the quiz attempt. $olduserid = $data->userid; $data->userid = $this->get_mappingid('user', $olduserid, 0); if ($data->userid === 0) { $this->log('Mapped user ID not found for user ' . $olduserid . ', quiz ' . $this->get_new_parentid('quiz') . ', attempt ' . $data->attempt . '. Skipping quiz attempt', backup::LOG_INFO); $this->currentquizattempt = null; return; } if (!empty($data->timecheckstate)) { $data->timecheckstate = $this->apply_date_offset($data->timecheckstate); } else { $data->timecheckstate = 0; } if (!isset($data->gradednotificationsenttime)) { // For attempts restored from old Moodle sites before this field // existed, we never want to send emails. $data->gradednotificationsenttime = $data->timefinish; } // Deals with up-grading pre-2.3 back-ups to 2.3+. if (!isset($data->state)) { if ($data->timefinish > 0) { $data->state = 'finished'; } else { $data->state = 'inprogress'; } } // The data is actually inserted into the database later in inform_new_usage_id. $this->currentquizattempt = clone($data); } protected function process_quiz_attempt_legacy($data) { global $DB; $this->process_quiz_attempt($data); $quiz = $DB->get_record('quiz', ['id' => $this->get_new_parentid('quiz')]); $quiz->oldquestions = $this->oldquizlayout; $this->process_legacy_quiz_attempt_data($data, $quiz); } protected function inform_new_usage_id($newusageid) { global $DB; $data = $this->currentquizattempt; if ($data === null) { return; } $oldid = $data->id; $data->uniqueid = $newusageid; $newitemid = $DB->insert_record('quiz_attempts', $data); // Save quiz_attempt->id mapping, because logs use it. $this->set_mapping('quiz_attempt', $oldid, $newitemid, false); } protected function after_execute() { global $DB; parent::after_execute(); // Add quiz related files, no need to match by itemname (just internally handled context). $this->add_related_files('mod_quiz', 'intro', null); // Add feedback related files, matching by itemname = 'quiz_feedback'. $this->add_related_files('mod_quiz', 'feedback', 'quiz_feedback'); if (!$this->sectioncreated) { $DB->insert_record('quiz_sections', [ 'quizid' => $this->get_new_parentid('quiz'), 'firstslot' => 1, 'heading' => '', 'shufflequestions' => $this->legacyshufflequestionsoption]); } } protected function after_restore() { parent::after_restore(); // Delete old random questions that have been converted to set references. foreach (array_keys($this->oldquestionids) as $oldquestionid) { question_delete_question($oldquestionid); } } } PK9B\,X}backup/moodle1/lib.phpnu[. /** * Provides support for the conversion of moodle1 backup to the moodle2 format * Based off of a template @ http://docs.moodle.org/dev/Backup_1.9_conversion_for_developers * * @package mod_quiz * @copyright 2011 Aparup Banerjee * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); /** * Quiz conversion handler */ class moodle1_mod_quiz_handler extends moodle1_mod_handler { /** @var moodle1_file_manager */ protected $fileman = null; /** @var int cmid */ protected $moduleid = null; /** * Declare the paths in moodle.xml we are able to convert * * The method returns list of {@link convert_path} instances. * For each path returned, the corresponding conversion method must be * defined. * * Note that the path /MOODLE_BACKUP/COURSE/MODULES/MOD/QUIZ does not * actually exist in the file. The last element with the module name was * appended by the moodle1_converter class. * * @return array of {@link convert_path} instances */ public function get_paths() { return [ new convert_path( 'quiz', '/MOODLE_BACKUP/COURSE/MODULES/MOD/QUIZ', [ 'newfields' => [ 'showuserpicture' => 0, 'questiondecimalpoints' => -1, 'introformat' => 0, 'showblocks' => 0, ], ] ), new convert_path('quiz_question_instances', '/MOODLE_BACKUP/COURSE/MODULES/MOD/QUIZ/QUESTION_INSTANCES'), new convert_path('quiz_question_instance', '/MOODLE_BACKUP/COURSE/MODULES/MOD/QUIZ/QUESTION_INSTANCES/QUESTION_INSTANCE', [ 'renamefields' => [ 'question' => 'questionid', 'grade' => 'maxmark', ], ] ), new convert_path('quiz_feedbacks', '/MOODLE_BACKUP/COURSE/MODULES/MOD/QUIZ/FEEDBACKS'), new convert_path('quiz_feedback', '/MOODLE_BACKUP/COURSE/MODULES/MOD/QUIZ/FEEDBACKS/FEEDBACK', [ 'newfields' => [ 'feedbacktextformat' => FORMAT_HTML, ] ] ) ]; } /** * This is executed every time we have one /MOODLE_BACKUP/COURSE/MODULES/MOD/QUIZ * data available */ public function process_quiz($data) { global $CFG; // Replay the upgrade step 2008081501. if (is_null($data['sumgrades'])) { $data['sumgrades'] = 0; // TODO for user data: quiz_attempts SET sumgrades=0 WHERE sumgrades IS NULL. // TODO for user data: quiz_grades.grade should be not be null , convert to default 0. } // Replay the upgrade step 2009042000. if ($CFG->texteditors !== 'textarea') { $data['intro'] = text_to_html($data['intro'], false, false, true); $data['introformat'] = FORMAT_HTML; } // Replay the upgrade step 2009031001. $data['timelimit'] *= 60; // Get the course module id and context id. $instanceid = $data['id']; $cminfo = $this->get_cminfo($instanceid); $this->moduleid = $cminfo['id']; $contextid = $this->converter->get_contextid(CONTEXT_MODULE, $this->moduleid); // Get a fresh new file manager for this instance. $this->fileman = $this->converter->get_file_manager($contextid, 'mod_quiz'); // Convert course files embedded into the intro. $this->fileman->filearea = 'intro'; $this->fileman->itemid = 0; $data['intro'] = moodle1_converter::migrate_referenced_files( $data['intro'], $this->fileman); // Start writing quiz.xml. $this->open_xml_writer("activities/quiz_{$this->moduleid}/quiz.xml"); $this->xmlwriter->begin_tag('activity', ['id' => $instanceid, 'moduleid' => $this->moduleid, 'modulename' => 'quiz', 'contextid' => $contextid]); $this->xmlwriter->begin_tag('quiz', ['id' => $instanceid]); foreach ($data as $field => $value) { if ($field <> 'id') { $this->xmlwriter->full_tag($field, $value); } } return $data; } public function on_quiz_question_instances_start() { $this->xmlwriter->begin_tag('question_instances'); } public function on_quiz_question_instances_end() { $this->xmlwriter->end_tag('question_instances'); } public function process_quiz_question_instance($data) { $this->write_xml('question_instance', $data, ['/question_instance/id']); } public function on_quiz_feedbacks_start() { $this->xmlwriter->begin_tag('feedbacks'); } public function on_quiz_feedbacks_end() { $this->xmlwriter->end_tag('feedbacks'); } public function process_quiz_feedback($data) { // Replay the upgrade step 2010122302. if (is_null($data['mingrade'])) { $data['mingrade'] = 0; } if (is_null($data['maxgrade'])) { $data['maxgrade'] = 0; } $this->write_xml('feedback', $data, ['/feedback/id']); } /** * This is executed when we reach the closing tag of our 'quiz' path */ public function on_quiz_end() { // Append empty subpath element. $this->write_xml('overrides', []); // Finish writing quiz.xml. $this->xmlwriter->end_tag('quiz'); $this->xmlwriter->end_tag('activity'); $this->close_xml_writer(); // Write inforef.xml. $this->open_xml_writer("activities/quiz_{$this->moduleid}/inforef.xml"); $this->xmlwriter->begin_tag('inforef'); $this->xmlwriter->begin_tag('fileref'); foreach ($this->fileman->get_fileids() as $fileid) { $this->write_xml('file', ['id' => $fileid]); } $this->xmlwriter->end_tag('fileref'); $this->xmlwriter->end_tag('inforef'); $this->close_xml_writer(); } } PK9B\N summary.phpnu[. /** * This page prints a summary of a quiz attempt before it is submitted. * * @package mod_quiz * @copyright 2009 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ use mod_quiz\output\navigation_panel_attempt; use mod_quiz\output\renderer; require_once(__DIR__ . '/../../config.php'); require_once($CFG->dirroot . '/mod/quiz/locallib.php'); $attemptid = required_param('attempt', PARAM_INT); // The attempt to summarise. $cmid = optional_param('cmid', null, PARAM_INT); $PAGE->set_url('/mod/quiz/summary.php', ['attempt' => $attemptid]); // During quiz attempts, the browser back/forwards buttons should force a reload. $PAGE->set_cacheable(false); $PAGE->set_secondary_active_tab("modulepage"); $attemptobj = quiz_create_attempt_handling_errors($attemptid, $cmid); // Check login. require_login($attemptobj->get_course(), false, $attemptobj->get_cm()); // Check that this attempt belongs to this user. if ($attemptobj->get_userid() != $USER->id) { if ($attemptobj->has_capability('mod/quiz:viewreports')) { redirect($attemptobj->review_url(null)); } else { throw new moodle_exception('notyourattempt', 'quiz', $attemptobj->view_url()); } } // Check capabilites. if (!$attemptobj->is_preview_user()) { $attemptobj->require_capability('mod/quiz:attempt'); } if ($attemptobj->is_preview_user()) { navigation_node::override_active_url($attemptobj->start_attempt_url()); } // Check access. $accessmanager = $attemptobj->get_access_manager(time()); $accessmanager->setup_attempt_page($PAGE); /** @var renderer $output */ $output = $PAGE->get_renderer('mod_quiz'); $messages = $accessmanager->prevent_access(); if (!$attemptobj->is_preview_user() && $messages) { throw new \moodle_exception('attempterror', 'quiz', $attemptobj->view_url(), $output->access_messages($messages)); } if ($accessmanager->is_preflight_check_required($attemptobj->get_attemptid())) { redirect($attemptobj->start_attempt_url(null)); } $displayoptions = $attemptobj->get_display_options(false); // If the attempt is now overdue, or abandoned, deal with that. $attemptobj->handle_if_time_expired(time(), true); // If the attempt is already closed, redirect them to the review page. if ($attemptobj->is_finished()) { redirect($attemptobj->review_url()); } \core\session\manager::keepalive(); // Try to prevent sessions expiring during quiz attempts. // Arrange for the navigation to be displayed. if (empty($attemptobj->get_quiz()->showblocks)) { $PAGE->blocks->show_only_fake_blocks(); } $navbc = $attemptobj->get_navigation_panel($output, navigation_panel_attempt::class, -1); $regions = $PAGE->blocks->get_regions(); $PAGE->blocks->add_fake_block($navbc, reset($regions)); $PAGE->navbar->add(get_string('summaryofattempt', 'quiz')); $PAGE->set_title($attemptobj->summary_page_title()); $PAGE->set_heading($attemptobj->get_course()->fullname); $PAGE->activityheader->disable(); // Display the page. echo $output->summary_page($attemptobj, $displayoptions); // Log this page view. $attemptobj->fire_attempt_summary_viewed_event(); PK9B\K9--view.phpnu[. /** * This page is the entry page into the quiz UI. Displays information about the * quiz to students and teachers, and lets students see their previous attempts. * * @package mod_quiz * @copyright 1999 onwards Martin Dougiamas {@link http://moodle.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ use core\output\notification; use mod_quiz\access_manager; use mod_quiz\output\list_of_attempts; use mod_quiz\output\renderer; use mod_quiz\output\view_page; use mod_quiz\quiz_attempt; use mod_quiz\quiz_settings; require_once(__DIR__ . '/../../config.php'); require_once($CFG->libdir.'/gradelib.php'); require_once($CFG->dirroot.'/mod/quiz/locallib.php'); require_once($CFG->libdir . '/completionlib.php'); require_once($CFG->dirroot . '/course/format/lib.php'); $id = optional_param('id', 0, PARAM_INT); // Course Module ID, or ... $q = optional_param('q', 0, PARAM_INT); // Quiz ID. if ($id) { $quizobj = quiz_settings::create_for_cmid($id, $USER->id); } else { $quizobj = quiz_settings::create($q, $USER->id); } $quiz = $quizobj->get_quiz(); $cm = $quizobj->get_cm(); $course = $quizobj->get_course(); // Check login and get context. require_login($course, false, $cm); $context = $quizobj->get_context(); require_capability('mod/quiz:view', $context); // Cache some other capabilities we use several times. $canattempt = has_capability('mod/quiz:attempt', $context); $canreviewmine = has_capability('mod/quiz:reviewmyattempts', $context); $canpreview = has_capability('mod/quiz:preview', $context); // Create an object to manage all the other (non-roles) access rules. $timenow = time(); $accessmanager = new access_manager($quizobj, $timenow, has_capability('mod/quiz:ignoretimelimits', $context, null, false)); // Trigger course_module_viewed event and completion. quiz_view($quiz, $course, $cm, $context); // Initialize $PAGE, compute blocks. $PAGE->set_url('/mod/quiz/view.php', ['id' => $cm->id]); // On the quiz view page, the browser back/forwards buttons should force a reload. $PAGE->set_cacheable(false); // Create view object which collects all the information the renderer will need. $viewobj = new view_page(); $viewobj->accessmanager = $accessmanager; $viewobj->canreviewmine = $canreviewmine || $canpreview; // Get this user's attempts. $attempts = quiz_get_user_attempts($quiz->id, $USER->id, 'finished', true); $lastfinishedattempt = end($attempts); $unfinished = false; $unfinishedattemptid = null; if ($unfinishedattempt = quiz_get_user_attempt_unfinished($quiz->id, $USER->id)) { $attempts[] = $unfinishedattempt; // If the attempt is now overdue, deal with that - and pass isonline = false. // We want the student notified in this case. $quizobj->create_attempt_object($unfinishedattempt)->handle_if_time_expired(time(), false); $unfinished = $unfinishedattempt->state == quiz_attempt::IN_PROGRESS || $unfinishedattempt->state == quiz_attempt::OVERDUE; if (!$unfinished) { $lastfinishedattempt = $unfinishedattempt; } $unfinishedattemptid = $unfinishedattempt->id; $unfinishedattempt = null; // To make it clear we do not use this again. } $numattempts = count($attempts); $gradeitemmarks = $quizobj->get_grade_calculator()->compute_grade_item_totals_for_attempts( array_column($attempts, 'uniqueid')); $viewobj->attempts = $attempts; $viewobj->attemptobjs = []; foreach ($attempts as $attempt) { $attemptobj = new quiz_attempt($attempt, $quiz, $cm, $course, false); $attemptobj->set_grade_item_totals($gradeitemmarks[$attempt->uniqueid]); $viewobj->attemptobjs[] = $attemptobj; } $viewobj->attemptslist = new list_of_attempts($timenow); foreach (array_reverse($viewobj->attemptobjs) as $attemptobj) { $viewobj->attemptslist->add_attempt($attemptobj); } // Work out the final grade, checking whether it was overridden in the gradebook. // First, get an initial grade to display. if (!$canpreview) { $mygrade = quiz_get_best_grade($quiz, $USER->id); } else if ($lastfinishedattempt) { // Users who can preview the quiz don't get a proper grade, so work out a // plausible value to display instead, so the page looks right. $mygrade = quiz_rescale_grade($lastfinishedattempt->sumgrades, $quiz, false); } else { $mygrade = null; } // Now, check the grade in the gradebook, if there is one. $mygradeoverridden = false; $gradebookfeedback = ''; $gradeitem = grade_item::fetch([ 'itemtype' => 'mod', 'itemmodule' => 'quiz', 'iteminstance' => $quiz->id, 'itemnumber' => 0, 'courseid' => $course->id, ]); // If there's a grade item grade, then get that grade for this user. // Users who can preview the quiz (eg teachers) won't have a proper grade, // so no point getting their grades here. if (!$canpreview && $gradeitem) { $grade = $gradeitem->get_grade($USER->id, false); $mygrade = $grade->finalgrade; // Use this grade to display in the view page. if ($grade->overridden) { if ($gradeitem->needsupdate) { // It is Error, but let's be consistent with the old code. $mygrade = 0; } $mygradeoverridden = true; } if (!empty($grade->feedback)) { $gradebookfeedback = $grade->feedback; } } $title = $course->shortname . ': ' . format_string($quiz->name); $PAGE->set_title($title); $PAGE->set_heading($course->fullname); if (html_is_blank($quiz->intro)) { $PAGE->activityheader->set_description(''); } $PAGE->add_body_class('limitedwidth'); /** @var renderer $output */ $output = $PAGE->get_renderer('mod_quiz'); // Print overall stats and table with existing attempts. if ($attempts) { // Work out which columns we need, taking account what data is available in each attempt. list($someoptions, $alloptions) = quiz_get_combined_reviewoptions($quiz, $attempts); $viewobj->attemptcolumn = $quiz->attempts != 1; $viewobj->gradecolumn = $someoptions->marks >= question_display_options::MARK_AND_MAX && quiz_has_grades($quiz); $viewobj->markcolumn = $viewobj->gradecolumn && ($quiz->grade != $quiz->sumgrades); $viewobj->overallstats = $lastfinishedattempt && $alloptions->marks >= question_display_options::MARK_AND_MAX; $viewobj->feedbackcolumn = quiz_has_feedback($quiz) && $alloptions->overallfeedback; } $viewobj->timenow = $timenow; $viewobj->numattempts = $numattempts; $viewobj->mygrade = $mygrade; $viewobj->moreattempts = $unfinished || !$accessmanager->is_finished($numattempts, $lastfinishedattempt); $viewobj->mygradeoverridden = $mygradeoverridden; $viewobj->gradebookfeedback = $gradebookfeedback; $viewobj->lastfinishedattempt = $lastfinishedattempt; $viewobj->canedit = has_capability('mod/quiz:manage', $context); $viewobj->editurl = new moodle_url('/mod/quiz/edit.php', ['cmid' => $cm->id]); $viewobj->backtocourseurl = new moodle_url('/course/view.php', ['id' => $course->id]); $viewobj->startattempturl = $quizobj->start_attempt_url(); if ($accessmanager->is_preflight_check_required($unfinishedattemptid)) { $viewobj->preflightcheckform = $accessmanager->get_preflight_check_form( $viewobj->startattempturl, $unfinishedattemptid); } $viewobj->popuprequired = $accessmanager->attempt_must_be_in_popup(); $viewobj->popupoptions = $accessmanager->get_popup_options(); // Display information about this quiz. $viewobj->infomessages = $viewobj->accessmanager->describe_rules(); if ($quiz->attempts != 1) { $viewobj->infomessages[] = get_string('gradingmethod', 'quiz', quiz_get_grading_option_name($quiz->grademethod)); } // Inform user of the grade to pass if non-zero. if ($gradeitem && grade_floats_different($gradeitem->gradepass, 0)) { $a = new stdClass(); $a->grade = quiz_format_grade($quiz, $gradeitem->gradepass); $a->maxgrade = quiz_format_grade($quiz, $quiz->grade); $viewobj->infomessages[] = get_string('gradetopassoutof', 'quiz', $a); } // Determine whether a start attempt button should be displayed. $viewobj->quizhasquestions = $quizobj->has_questions(); $viewobj->preventmessages = []; if (!$viewobj->quizhasquestions) { $viewobj->buttontext = ''; } else { if ($unfinished) { if ($canpreview) { $viewobj->buttontext = get_string('continuepreview', 'quiz'); } else if ($canattempt) { $viewobj->buttontext = get_string('continueattemptquiz', 'quiz'); } } else { if ($canpreview) { $viewobj->buttontext = get_string('previewquizstart', 'quiz'); } else if ($canattempt) { $viewobj->preventmessages = $viewobj->accessmanager->prevent_new_attempt( $viewobj->numattempts, $viewobj->lastfinishedattempt); if ($viewobj->preventmessages) { $viewobj->buttontext = ''; } else if ($viewobj->numattempts == 0) { $viewobj->buttontext = get_string('attemptquiz', 'quiz'); } else { $viewobj->buttontext = get_string('reattemptquiz', 'quiz'); } } } // Users who can preview the quiz should be able to see all messages for not being able to access the quiz. if ($canpreview) { $viewobj->preventmessages = $viewobj->accessmanager->prevent_access(); } else if ($viewobj->buttontext) { // If, so far, we think a button should be printed, so check if they will be allowed to access it. if (!$viewobj->moreattempts) { $viewobj->buttontext = ''; } else if ($canattempt) { $viewobj->preventmessages = $viewobj->accessmanager->prevent_access(); if ($viewobj->preventmessages) { $viewobj->buttontext = ''; } } } // If the quiz has any invalid questions, we cannot attempt it. if (in_array('missingtype', $quizobj->get_all_question_types_used())) { $viewobj->preventmessages[] = $OUTPUT->notification( get_string('quizinvalidquestions', 'mod_quiz'), notification::NOTIFY_ERROR, false); $viewobj->buttontext = ''; } } $viewobj->showbacktocourse = ($viewobj->buttontext === '' && course_get_format($course)->has_view_page()); echo $OUTPUT->header(); if (!empty($gradinginfo->errors)) { foreach ($gradinginfo->errors as $error) { $errortext = new notification($error, notification::NOTIFY_ERROR); echo $OUTPUT->render($errortext); } } if (isguestuser()) { // Guests can't do a quiz, so offer them a choice of logging in or going back. echo $output->view_page_guest($course, $quiz, $cm, $context, $viewobj->infomessages, $viewobj); } else if (!isguestuser() && !($canattempt || $canpreview || $viewobj->canreviewmine)) { // If they are not enrolled in this course in a good enough role, tell them to enrol. echo $output->view_page_notenrolled($course, $quiz, $cm, $context, $viewobj->infomessages, $viewobj); } else { echo $output->view_page($course, $quiz, $cm, $context, $viewobj); } echo $OUTPUT->footer(); PK9B\0#;;processattempt.phpnu[. /** * This page deals with processing responses during an attempt at a quiz. * * People will normally arrive here from a form submission on attempt.php or * summary.php, and once the responses are processed, they will be redirected to * attempt.php or summary.php. * * This code used to be near the top of attempt.php, if you are looking for CVS history. * * @package mod_quiz * @copyright 2009 Tim Hunt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ use mod_quiz\quiz_attempt; require_once(__DIR__ . '/../../config.php'); require_once($CFG->dirroot . '/mod/quiz/locallib.php'); // Remember the current time as the time any responses were submitted // (so as to make sure students don't get penalized for slow processing on this page). $timenow = time(); // Get submitted parameters. $attemptid = required_param('attempt', PARAM_INT); $thispage = optional_param('thispage', 0, PARAM_INT); $nextpage = optional_param('nextpage', 0, PARAM_INT); $previous = optional_param('previous', false, PARAM_BOOL); $next = optional_param('next', false, PARAM_BOOL); $finishattempt = optional_param('finishattempt', false, PARAM_BOOL); $timeup = optional_param('timeup', 0, PARAM_BOOL); // True if form was submitted by timer. $mdlscrollto = optional_param('mdlscrollto', '', PARAM_RAW); $cmid = optional_param('cmid', null, PARAM_INT); $attemptobj = quiz_create_attempt_handling_errors($attemptid, $cmid); // Set $nexturl now. if ($next) { $page = $nextpage; } else if ($previous && $thispage > 0) { $page = $thispage - 1; } else { $page = $thispage; } if ($page == -1) { $nexturl = $attemptobj->summary_url(); } else { $nexturl = $attemptobj->attempt_url(null, $page); if ($mdlscrollto !== '') { $nexturl->param('mdlscrollto', $mdlscrollto); } } // Check login. require_login($attemptobj->get_course(), false, $attemptobj->get_cm()); require_sesskey(); // Check that this attempt belongs to this user. if ($attemptobj->get_userid() != $USER->id) { throw new moodle_exception('notyourattempt', 'quiz', $attemptobj->view_url()); } // Check capabilities. if (!$attemptobj->is_preview_user()) { $attemptobj->require_capability('mod/quiz:attempt'); } // If the attempt is already closed, send them to the review page. if ($attemptobj->is_finished()) { throw new moodle_exception('attemptalreadyclosed', 'quiz', $attemptobj->view_url()); } // If this page cannot be accessed, notify user and send them to the correct page. if (!$finishattempt && !$attemptobj->check_page_access($thispage)) { throw new moodle_exception('submissionoutofsequencefriendlymessage', 'question', $attemptobj->attempt_url(null, $attemptobj->get_currentpage())); } // Process the attempt, getting the new status for the attempt. $status = $attemptobj->process_attempt($timenow, $finishattempt, $timeup, $thispage); if ($status == quiz_attempt::OVERDUE) { redirect($attemptobj->summary_url()); } else if ($status == quiz_attempt::IN_PROGRESS) { redirect($nexturl); } else { // Attempt abandoned or finished. redirect($attemptobj->review_url()); } PK9B\88pix/navflagged.svgnu[ PK9B\๐pix/flag-on.svgnu[ image/svg+xml PK9B\]q--pix/warningtriangle.svgnu[ image/svg+xml PK9B\TMA pix/monologo.svgnu[ PK9B\⇶c{c{lib.phpnu[. /** * Library of functions for the quiz module. * * This contains functions that are called also from outside the quiz module * Functions that are only called by the quiz module itself are in {@link locallib.php} * * @package mod_quiz * @copyright 1999 onwards Martin Dougiamas {@link http://moodle.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ use core_question\local\bank\question_bank_helper; use qbank_managecategories\helper; defined('MOODLE_INTERNAL') || die(); use mod_quiz\access_manager; use mod_quiz\grade_calculator; use mod_quiz\question\bank\custom_view; use mod_quiz\question\bank\qbank_helper; use mod_quiz\question\display_options; use mod_quiz\question\qubaids_for_quiz; use mod_quiz\question\qubaids_for_users_attempts; use core_question\statistics\questions\all_calculated_for_qubaid_condition; use mod_quiz\local\override_cache; use mod_quiz\quiz_attempt; use mod_quiz\quiz_settings; require_once($CFG->dirroot . '/calendar/lib.php'); require_once($CFG->dirroot . '/question/editlib.php'); /**#@+ * Option controlling what options are offered on the quiz settings form. */ define('QUIZ_MAX_ATTEMPT_OPTION', 10); define('QUIZ_MAX_QPP_OPTION', 50); define('QUIZ_MAX_DECIMAL_OPTION', 5); define('QUIZ_MAX_Q_DECIMAL_OPTION', 7); /**#@-*/ /**#@+ * Options determining how the grades from individual attempts are combined to give * the overall grade for a user */ define('QUIZ_GRADEHIGHEST', '1'); define('QUIZ_GRADEAVERAGE', '2'); define('QUIZ_ATTEMPTFIRST', '3'); define('QUIZ_ATTEMPTLAST', '4'); /**#@-*/ /** * @var int If start and end date for the quiz are more than this many seconds apart * they will be represented by two separate events in the calendar */ define('QUIZ_MAX_EVENT_LENGTH', 5*24*60*60); // 5 days. /**#@+ * Options for navigation method within quizzes. */ define('QUIZ_NAVMETHOD_FREE', 'free'); define('QUIZ_NAVMETHOD_SEQ', 'sequential'); /**#@-*/ /** * Event types. */ define('QUIZ_EVENT_TYPE_OPEN', 'open'); define('QUIZ_EVENT_TYPE_CLOSE', 'close'); require_once(__DIR__ . '/deprecatedlib.php'); /** * Given an object containing all the necessary data, * (defined by the form in mod_form.php) this function * will create a new instance and return the id number * of the new instance. * * @param stdClass $quiz the data that came from the form. * @return mixed the id of the new instance on success, * false or a string error message on failure. */ function quiz_add_instance($quiz) { global $DB; $cmid = $quiz->coursemodule; // Process the options from the form. $quiz->timecreated = time(); $result = quiz_process_options($quiz); if ($result && is_string($result)) { return $result; } // Try to store it in the database. $quiz->id = $DB->insert_record('quiz', $quiz); // Create the first section for this quiz. $DB->insert_record('quiz_sections', ['quizid' => $quiz->id, 'firstslot' => 1, 'heading' => '', 'shufflequestions' => 0]); // Do the processing required after an add or an update. quiz_after_add_or_update($quiz); return $quiz->id; } /** * Given an object containing all the necessary data, * (defined by the form in mod_form.php) this function * will update an existing instance with new data. * * @param stdClass $quiz the data that came from the form. * @param stdClass $mform no longer used. * @return mixed true on success, false or a string error message on failure. */ function quiz_update_instance($quiz, $mform) { global $CFG, $DB; require_once($CFG->dirroot . '/mod/quiz/locallib.php'); // Process the options from the form. $result = quiz_process_options($quiz); if ($result && is_string($result)) { return $result; } // Get the current value, so we can see what changed. $oldquiz = $DB->get_record('quiz', ['id' => $quiz->instance]); // We need two values from the existing DB record that are not in the form, // in some of the function calls below. $quiz->sumgrades = $oldquiz->sumgrades; $quiz->grade = $oldquiz->grade; // Update the database. $quiz->id = $quiz->instance; $DB->update_record('quiz', $quiz); // Do the processing required after an add or an update. quiz_after_add_or_update($quiz); if ($oldquiz->grademethod != $quiz->grademethod) { $gradecalculator = quiz_settings::create($quiz->id)->get_grade_calculator(); $gradecalculator->recompute_all_final_grades(); quiz_update_grades($quiz); } $quizdateschanged = $oldquiz->timelimit != $quiz->timelimit || $oldquiz->timeclose != $quiz->timeclose || $oldquiz->graceperiod != $quiz->graceperiod; if ($quizdateschanged) { quiz_update_open_attempts(['quizid' => $quiz->id]); } // Delete any previous preview attempts. quiz_delete_previews($quiz); // Repaginate, if asked to. if (!empty($quiz->repaginatenow) && !quiz_has_attempts($quiz->id)) { quiz_repaginate_questions($quiz->id, $quiz->questionsperpage); } return true; } /** * Given an ID of an instance of this module, * this function will permanently delete the instance * and any data that depends on it. * * @param int $id the id of the quiz to delete. * @return bool success or failure. */ function quiz_delete_instance($id) { global $DB; $quiz = $DB->get_record('quiz', ['id' => $id], '*', MUST_EXIST); quiz_delete_all_attempts($quiz); // Delete all overrides, and for performance do not log or check permissions. $quizobj = quiz_settings::create($quiz->id); $quizobj->get_override_manager()->delete_all_overrides(shouldlog: false); quiz_delete_references($quiz->id); // We need to do the following deletes before we try and delete randoms, otherwise they would still be 'in use'. $DB->delete_records('quiz_slots', ['quizid' => $quiz->id]); $DB->delete_records('quiz_sections', ['quizid' => $quiz->id]); $DB->delete_records('quiz_feedback', ['quizid' => $quiz->id]); access_manager::delete_settings($quiz); $events = $DB->get_records('event', ['modulename' => 'quiz', 'instance' => $quiz->id]); foreach ($events as $event) { $event = calendar_event::load($event); $event->delete(); } quiz_grade_item_delete($quiz); // We must delete the module record after we delete the grade item. $DB->delete_records('quiz', ['id' => $quiz->id]); return true; } /** * Updates a quiz object with override information for a user. * * Algorithm: For each quiz setting, if there is a matching user-specific override, * then use that otherwise, if there are group-specific overrides, return the most * lenient combination of them. If neither applies, leave the quiz setting unchanged. * * Special case: if there is more than one password that applies to the user, then * quiz->extrapasswords will contain an array of strings giving the remaining * passwords. * * @param stdClass $quiz The quiz object. * @param int $userid The userid. * @return stdClass $quiz The updated quiz object. */ function quiz_update_effective_access($quiz, $userid) { global $DB; // Check for user override. $override = $DB->get_record('quiz_overrides', ['quiz' => $quiz->id, 'userid' => $userid]); if (!$override) { $override = new stdClass(); $override->timeopen = null; $override->timeclose = null; $override->timelimit = null; $override->attempts = null; $override->password = null; } // Check for group overrides. $groupings = groups_get_user_groups($quiz->course, $userid); if (!empty($groupings[0])) { // Select all overrides that apply to the User's groups. [$extra, $params] = $DB->get_in_or_equal(array_values($groupings[0])); $sql = "SELECT * FROM {quiz_overrides} WHERE groupid $extra AND quiz = ?"; $params[] = $quiz->id; $records = $DB->get_records_sql($sql, $params); // Combine the overrides. $opens = []; $closes = []; $limits = []; $attempts = []; $passwords = []; foreach ($records as $gpoverride) { if (isset($gpoverride->timeopen)) { $opens[] = $gpoverride->timeopen; } if (isset($gpoverride->timeclose)) { $closes[] = $gpoverride->timeclose; } if (isset($gpoverride->timelimit)) { $limits[] = $gpoverride->timelimit; } if (isset($gpoverride->attempts)) { $attempts[] = $gpoverride->attempts; } if (isset($gpoverride->password)) { $passwords[] = $gpoverride->password; } } // If there is a user override for a setting, ignore the group override. if (is_null($override->timeopen) && count($opens)) { $override->timeopen = min($opens); } if (is_null($override->timeclose) && count($closes)) { if (in_array(0, $closes)) { $override->timeclose = 0; } else { $override->timeclose = max($closes); } } if (is_null($override->timelimit) && count($limits)) { if (in_array(0, $limits)) { $override->timelimit = 0; } else { $override->timelimit = max($limits); } } if (is_null($override->attempts) && count($attempts)) { if (in_array(0, $attempts)) { $override->attempts = 0; } else { $override->attempts = max($attempts); } } if (is_null($override->password) && count($passwords)) { $override->password = array_shift($passwords); if (count($passwords)) { $override->extrapasswords = $passwords; } } } // Merge with quiz defaults. $keys = ['timeopen', 'timeclose', 'timelimit', 'attempts', 'password', 'extrapasswords']; foreach ($keys as $key) { if (isset($override->{$key})) { $quiz->{$key} = $override->{$key}; } } return $quiz; } /** * Delete all the attempts belonging to a quiz. * * @param stdClass $quiz The quiz object. */ function quiz_delete_all_attempts($quiz) { global $CFG, $DB; require_once($CFG->dirroot . '/mod/quiz/locallib.php'); question_engine::delete_questions_usage_by_activities(new qubaids_for_quiz($quiz->id)); $DB->delete_records('quiz_attempts', ['quiz' => $quiz->id]); $DB->delete_records('quiz_grades', ['quiz' => $quiz->id]); } /** * Delete all the attempts belonging to a user in a particular quiz. * * @param \mod_quiz\quiz_settings $quiz The quiz object. * @param stdClass $user The user object. */ function quiz_delete_user_attempts($quiz, $user) { global $CFG, $DB; require_once($CFG->dirroot . '/mod/quiz/locallib.php'); question_engine::delete_questions_usage_by_activities(new qubaids_for_users_attempts( $quiz->get_quizid(), $user->id, 'all')); $params = [ 'quiz' => $quiz->get_quizid(), 'userid' => $user->id, ]; $DB->delete_records('quiz_attempts', $params); $DB->delete_records('quiz_grades', $params); } /** * Get the best current grade for a particular user in a quiz. * * @param stdClass $quiz the quiz settings. * @param int $userid the id of the user. * @return float the user's current grade for this quiz, or null if this user does * not have a grade on this quiz. */ function quiz_get_best_grade($quiz, $userid) { global $DB; $grade = $DB->get_field('quiz_grades', 'grade', ['quiz' => $quiz->id, 'userid' => $userid]); // Need to detect errors/no result, without catching 0 grades. if ($grade === false) { return null; } return $grade + 0; // Convert to number. } /** * Is this a graded quiz? If this method returns true, you can assume that * $quiz->grade and $quiz->sumgrades are non-zero (for example, if you want to * divide by them). * * @param stdClass $quiz a row from the quiz table. * @return bool whether this is a graded quiz. */ function quiz_has_grades($quiz) { return $quiz->grade >= grade_calculator::ALMOST_ZERO && $quiz->sumgrades >= grade_calculator::ALMOST_ZERO; } /** * Does this quiz allow multiple tries? * * @return bool */ function quiz_allows_multiple_tries($quiz) { $bt = question_engine::get_behaviour_type($quiz->preferredbehaviour); return $bt->allows_multiple_submitted_responses(); } /** * Return a small object with summary information about what a * user has done with a given particular instance of this module * Used for user activity reports. * $return->time = the time they did it * $return->info = a short text description * * @param stdClass $course * @param stdClass $user * @param stdClass $mod * @param stdClass $quiz * @return stdClass|null */ function quiz_user_outline($course, $user, $mod, $quiz) { global $DB, $CFG; require_once($CFG->libdir . '/gradelib.php'); $grades = grade_get_grades($course->id, 'mod', 'quiz', $quiz->id, $user->id); if (empty($grades->items[0]->grades)) { return null; } else { $grade = reset($grades->items[0]->grades); } $result = new stdClass(); // If the user can't see hidden grades, don't return that information. $gitem = grade_item::fetch(['id' => $grades->items[0]->id]); if (!$gitem->hidden || has_capability('moodle/grade:viewhidden', context_course::instance($course->id))) { $result->info = get_string('gradenoun') . ': ' . $grade->str_long_grade; } else { $result->info = get_string('gradenoun') . ': ' . get_string('hidden', 'grades'); } $result->time = grade_get_date_for_user_grade($grade, $user); return $result; } /** * Print a detailed representation of what a user has done with * a given particular instance of this module, for user activity reports. * * @param stdClass $course * @param stdClass $user * @param stdClass $mod * @param stdClass $quiz * @return bool */ function quiz_user_complete($course, $user, $mod, $quiz) { global $DB, $CFG, $OUTPUT; require_once($CFG->libdir . '/gradelib.php'); require_once($CFG->dirroot . '/mod/quiz/locallib.php'); $grades = grade_get_grades($course->id, 'mod', 'quiz', $quiz->id, $user->id); if (!empty($grades->items[0]->grades)) { $grade = reset($grades->items[0]->grades); // If the user can't see hidden grades, don't return that information. $gitem = grade_item::fetch(['id' => $grades->items[0]->id]); if (!$gitem->hidden || has_capability('moodle/grade:viewhidden', context_course::instance($course->id))) { echo $OUTPUT->container(get_string('gradenoun').': '.$grade->str_long_grade); if ($grade->str_feedback) { echo $OUTPUT->container(get_string('feedback').': '.$grade->str_feedback); } } else { echo $OUTPUT->container(get_string('gradenoun') . ': ' . get_string('hidden', 'grades')); if ($grade->str_feedback) { echo $OUTPUT->container(get_string('feedback').': '.get_string('hidden', 'grades')); } } } if ($attempts = $DB->get_records('quiz_attempts', ['userid' => $user->id, 'quiz' => $quiz->id], 'attempt')) { foreach ($attempts as $attempt) { echo get_string('attempt', 'quiz', $attempt->attempt) . ': '; if ($attempt->state != quiz_attempt::FINISHED) { echo quiz_attempt_state_name($attempt->state); } else { if (!isset($gitem)) { if (!empty($grades->items[0]->grades)) { $gitem = grade_item::fetch(['id' => $grades->items[0]->id]); } else { $gitem = new stdClass(); $gitem->hidden = true; } } if (!$gitem->hidden || has_capability('moodle/grade:viewhidden', context_course::instance($course->id))) { echo quiz_format_grade($quiz, $attempt->sumgrades) . '/' . quiz_format_grade($quiz, $quiz->sumgrades); } else { echo get_string('hidden', 'grades'); } echo ' - '.userdate($attempt->timefinish).'
'; } } } else { print_string('noattempts', 'quiz'); } return true; } /** * @param int|array $quizids A quiz ID, or an array of quiz IDs. * @param int $userid the userid. * @param string $status 'all', 'finished' or 'unfinished' to control * @param bool $includepreviews * @return array of all the user's attempts at this quiz. Returns an empty * array if there are none. */ function quiz_get_user_attempts($quizids, $userid, $status = 'finished', $includepreviews = false) { global $DB; $params = []; switch ($status) { case 'all': $statuscondition = ''; break; case 'finished': $statuscondition = ' AND state IN (:state1, :state2, :state3)'; $params['state1'] = quiz_attempt::FINISHED; $params['state2'] = quiz_attempt::ABANDONED; $params['state3'] = quiz_attempt::SUBMITTED; break; case 'unfinished': $statuscondition = ' AND state IN (:state1, :state2)'; $params['state1'] = quiz_attempt::IN_PROGRESS; $params['state2'] = quiz_attempt::OVERDUE; break; } $quizids = (array) $quizids; [$insql, $inparams] = $DB->get_in_or_equal($quizids, SQL_PARAMS_NAMED); $params += $inparams; $params['userid'] = $userid; $previewclause = ''; if (!$includepreviews) { $previewclause = ' AND preview = 0'; } return $DB->get_records_select('quiz_attempts', "quiz $insql AND userid = :userid" . $previewclause . $statuscondition, $params, 'quiz, attempt ASC'); } /** * Return grade for given user or all users. * * @param int $quizid id of quiz * @param int $userid optional user id, 0 means all users * @return array array of grades, false if none. These are raw grades. They should * be processed with quiz_format_grade for display. */ function quiz_get_user_grades($quiz, $userid = 0) { global $CFG, $DB; $params = [$quiz->id]; $usertest = ''; if ($userid) { $params[] = $userid; $usertest = 'AND u.id = ?'; } return $DB->get_records_sql(" SELECT u.id, u.id AS userid, qg.grade AS rawgrade, qg.timemodified AS dategraded, MAX(qa.timefinish) AS datesubmitted FROM {user} u JOIN {quiz_grades} qg ON u.id = qg.userid JOIN {quiz_attempts} qa ON qa.quiz = qg.quiz AND qa.userid = u.id WHERE qg.quiz = ? $usertest GROUP BY u.id, qg.grade, qg.timemodified", $params); } /** * Round a grade to the correct number of decimal places, and format it for display. * * @param stdClass $quiz The quiz table row, only $quiz->decimalpoints is used. * @param float|null $grade The grade to round and display (or null meaning no grade). * @return string */ function quiz_format_grade($quiz, $grade) { if (is_null($grade)) { return get_string('notyetgraded', 'quiz'); } return format_float($grade, $quiz->decimalpoints); } /** * Determine the correct number of decimal places required to format a grade. * * @param stdClass $quiz The quiz table row, only $quiz->decimalpoints and * ->questiondecimalpoints are used. * @return integer */ function quiz_get_grade_format($quiz) { if (empty($quiz->questiondecimalpoints)) { $quiz->questiondecimalpoints = -1; } if ($quiz->questiondecimalpoints == -1) { return $quiz->decimalpoints; } return $quiz->questiondecimalpoints; } /** * Round a grade to the correct number of decimal places, and format it for display. * * @param stdClass $quiz The quiz table row, only $quiz->decimalpoints is used. * @param float $grade The grade to round. * @return string */ function quiz_format_question_grade($quiz, $grade) { return format_float($grade, quiz_get_grade_format($quiz)); } /** * Update grades in central gradebook * * @category grade * @param stdClass $quiz the quiz settings. * @param int $userid specific user only, 0 means all users. * @param bool $nullifnone If a single user is specified and $nullifnone is true a grade item with a null rawgrade will be inserted */ function quiz_update_grades($quiz, $userid = 0, $nullifnone = true) { global $CFG, $DB; require_once($CFG->libdir . '/gradelib.php'); if ($quiz->grade == 0) { quiz_grade_item_update($quiz); } else if ($grades = quiz_get_user_grades($quiz, $userid)) { quiz_grade_item_update($quiz, $grades); } else if ($userid && $nullifnone) { $grade = new stdClass(); $grade->userid = $userid; $grade->rawgrade = null; quiz_grade_item_update($quiz, $grade); } else { quiz_grade_item_update($quiz); } } /** * Create or update the grade item for given quiz * * @category grade * @param stdClass $quiz object with extra cmidnumber * @param mixed $grades optional array/object of grade(s); 'reset' means reset grades in gradebook * @return int 0 if ok, error code otherwise */ function quiz_grade_item_update($quiz, $grades = null) { global $CFG, $OUTPUT; require_once($CFG->dirroot . '/mod/quiz/locallib.php'); require_once($CFG->libdir . '/gradelib.php'); if (property_exists($quiz, 'cmidnumber')) { // May not be always present. $params = ['itemname' => $quiz->name, 'idnumber' => $quiz->cmidnumber]; } else { $params = ['itemname' => $quiz->name]; } if ($quiz->grade > 0) { $params['gradetype'] = GRADE_TYPE_VALUE; $params['grademax'] = $quiz->grade; $params['grademin'] = 0; } else { $params['gradetype'] = GRADE_TYPE_NONE; } // What this is trying to do: // 1. If the quiz is set to not show grades while the quiz is still open, // and is set to show grades after the quiz is closed, then create the // grade_item with a show-after date that is the quiz close date. // 2. If the quiz is set to not show grades at either of those times, // create the grade_item as hidden. // 3. If the quiz is set to show grades, create the grade_item visible. $openreviewoptions = display_options::make_from_quiz($quiz, display_options::LATER_WHILE_OPEN); $closedreviewoptions = display_options::make_from_quiz($quiz, display_options::AFTER_CLOSE); if ($openreviewoptions->marks < question_display_options::MARK_AND_MAX && $closedreviewoptions->marks < question_display_options::MARK_AND_MAX) { $params['hidden'] = 1; } else if ($openreviewoptions->marks < question_display_options::MARK_AND_MAX && $closedreviewoptions->marks >= question_display_options::MARK_AND_MAX) { if ($quiz->timeclose) { $params['hidden'] = $quiz->timeclose; } else { $params['hidden'] = 1; } } else { // Either // a) both open and closed enabled // b) open enabled, closed disabled - we can not "hide after", // grades are kept visible even after closing. $params['hidden'] = 0; } if (!$params['hidden']) { // If the grade item is not hidden by the quiz logic, then we need to // hide it if the quiz is hidden from students. if (property_exists($quiz, 'visible')) { // Saving the quiz form, and cm not yet updated in the database. $params['hidden'] = !$quiz->visible; } else { $cm = get_coursemodule_from_instance('quiz', $quiz->id); $params['hidden'] = !$cm->visible; } } if ($grades === 'reset') { $params['reset'] = true; $grades = null; } $gradebook_grades = grade_get_grades($quiz->course, 'mod', 'quiz', $quiz->id); if (!empty($gradebook_grades->items)) { $grade_item = $gradebook_grades->items[0]; if ($grade_item->locked) { // NOTE: this is an extremely nasty hack! It is not a bug if this confirmation fails badly. --skodak. $confirm_regrade = optional_param('confirm_regrade', 0, PARAM_INT); if (!$confirm_regrade) { if (!AJAX_SCRIPT) { $message = get_string('gradeitemislocked', 'grades'); $back_link = $CFG->wwwroot . '/mod/quiz/report.php?q=' . $quiz->id . '&mode=overview'; $regrade_link = qualified_me() . '&confirm_regrade=1'; echo $OUTPUT->box_start('generalbox', 'notice'); echo '

'. $message .'

'; echo $OUTPUT->container_start('buttons'); echo $OUTPUT->single_button($regrade_link, get_string('regradeanyway', 'grades')); echo $OUTPUT->single_button($back_link, get_string('cancel')); echo $OUTPUT->container_end(); echo $OUTPUT->box_end(); } return GRADE_UPDATE_ITEM_LOCKED; } } } return grade_update('mod/quiz', $quiz->course, 'mod', 'quiz', $quiz->id, 0, $grades, $params); } /** * Delete grade item for given quiz * * @category grade * @param stdClass $quiz object * @return int */ function quiz_grade_item_delete($quiz) { global $CFG; require_once($CFG->libdir . '/gradelib.php'); return grade_update('mod/quiz', $quiz->course, 'mod', 'quiz', $quiz->id, 0, null, ['deleted' => 1]); } /** * This standard function will check all instances of this module * and make sure there are up-to-date events created for each of them. * If courseid = 0, then every quiz event in the site is checked, else * only quiz events belonging to the course specified are checked. * This function is used, in its new format, by restore_refresh_events() * * @param int $courseid * @param int|stdClass $instance Quiz module instance or ID. * @param int|stdClass $cm Course module object or ID (not used in this module). * @return bool */ function quiz_refresh_events($courseid = 0, $instance = null, $cm = null) { global $DB; // If we have instance information then we can just update the one event instead of updating all events. if (isset($instance)) { if (!is_object($instance)) { $instance = $DB->get_record('quiz', ['id' => $instance], '*', MUST_EXIST); } quiz_update_events($instance); return true; } if ($courseid == 0) { if (!$quizzes = $DB->get_records('quiz')) { return true; } } else { if (!$quizzes = $DB->get_records('quiz', ['course' => $courseid])) { return true; } } foreach ($quizzes as $quiz) { quiz_update_events($quiz); } return true; } /** * Returns all quiz graded users since a given time for specified quiz */ function quiz_get_recent_mod_activity(&$activities, &$index, $timestart, $courseid, $cmid, $userid = 0, $groupid = 0) { global $CFG, $USER, $DB; require_once($CFG->dirroot . '/mod/quiz/locallib.php'); $course = get_course($courseid); $modinfo = get_fast_modinfo($course); $cm = $modinfo->cms[$cmid]; $quiz = $DB->get_record('quiz', ['id' => $cm->instance]); if ($userid) { $userselect = "AND u.id = :userid"; $params['userid'] = $userid; } else { $userselect = ''; } if ($groupid) { $groupselect = 'AND gm.groupid = :groupid'; $groupjoin = 'JOIN {groups_members} gm ON gm.userid=u.id'; $params['groupid'] = $groupid; } else { $groupselect = ''; $groupjoin = ''; } $params['timestart'] = $timestart; $params['quizid'] = $quiz->id; $userfieldsapi = \core_user\fields::for_userpic(); $ufields = $userfieldsapi->get_sql('u', false, '', 'useridagain', false)->selects; if (!$attempts = $DB->get_records_sql(" SELECT qa.*, {$ufields} FROM {quiz_attempts} qa JOIN {user} u ON u.id = qa.userid $groupjoin WHERE qa.timefinish > :timestart AND qa.quiz = :quizid AND qa.preview = 0 $userselect $groupselect ORDER BY qa.timefinish ASC", $params)) { return; } $context = context_module::instance($cm->id); $accessallgroups = has_capability('moodle/site:accessallgroups', $context); $viewfullnames = has_capability('moodle/site:viewfullnames', $context); $grader = has_capability('mod/quiz:viewreports', $context); $groupmode = groups_get_activity_groupmode($cm, $course); $usersgroups = null; $aname = format_string($cm->name, true); foreach ($attempts as $attempt) { if ($attempt->userid != $USER->id) { if (!$grader) { // Grade permission required. continue; } if ($groupmode == SEPARATEGROUPS and !$accessallgroups) { $usersgroups = groups_get_all_groups($course->id, $attempt->userid, $cm->groupingid); $usersgroups = array_keys($usersgroups); if (!array_intersect($usersgroups, $modinfo->get_groups($cm->groupingid))) { continue; } } } $options = quiz_get_review_options($quiz, $attempt, $context); $tmpactivity = new stdClass(); $tmpactivity->type = 'quiz'; $tmpactivity->cmid = $cm->id; $tmpactivity->name = $aname; $tmpactivity->sectionnum = $cm->sectionnum; $tmpactivity->timestamp = $attempt->timefinish; $tmpactivity->content = new stdClass(); $tmpactivity->content->attemptid = $attempt->id; $tmpactivity->content->attempt = $attempt->attempt; if (quiz_has_grades($quiz) && $options->marks >= question_display_options::MARK_AND_MAX) { $tmpactivity->content->sumgrades = quiz_format_grade($quiz, $attempt->sumgrades); $tmpactivity->content->maxgrade = quiz_format_grade($quiz, $quiz->sumgrades); } else { $tmpactivity->content->sumgrades = null; $tmpactivity->content->maxgrade = null; } $tmpactivity->user = user_picture::unalias($attempt, null, 'useridagain'); $tmpactivity->user->fullname = fullname($tmpactivity->user, $viewfullnames); $activities[$index++] = $tmpactivity; } } function quiz_print_recent_mod_activity($activity, $courseid, $detail, $modnames) { global $CFG, $OUTPUT; echo ''; echo '
'; echo $OUTPUT->user_picture($activity->user, ['courseid' => $courseid]); echo ''; if ($detail) { $modname = $modnames[$activity->type]; echo '
'; echo $OUTPUT->image_icon('monologo', $modname, $activity->type); echo '' . $activity->name . ''; echo '
'; } echo '
'; echo get_string('attempt', 'quiz', $activity->content->attempt); if (isset($activity->content->maxgrade)) { $grades = $activity->content->sumgrades . ' / ' . $activity->content->maxgrade; echo ': (' . $grades . ')'; } echo '
'; echo '
'; echo '' . $activity->user->fullname . ' - ' . userdate($activity->timestamp); echo '
'; echo '
'; return; } /** * Pre-process the quiz options form data, making any necessary adjustments. * Called by add/update instance in this file. * * @param stdClass $quiz The variables set on the form. */ function quiz_process_options($quiz) { global $CFG; require_once($CFG->dirroot . '/mod/quiz/locallib.php'); require_once($CFG->libdir . '/questionlib.php'); $quiz->timemodified = time(); // Quiz name. if (!empty($quiz->name)) { $quiz->name = trim($quiz->name); } // Password field - different in form to stop browsers that remember passwords // getting confused. $quiz->password = $quiz->quizpassword; unset($quiz->quizpassword); // Quiz feedback. if (isset($quiz->feedbacktext)) { // Clean up the boundary text. for ($i = 0; $i < count($quiz->feedbacktext); $i += 1) { if (empty($quiz->feedbacktext[$i]['text'])) { $quiz->feedbacktext[$i]['text'] = ''; } else { $quiz->feedbacktext[$i]['text'] = trim($quiz->feedbacktext[$i]['text']); } } // Check the boundary value is a number or a percentage, and in range. $i = 0; while (!empty($quiz->feedbackboundaries[$i])) { $boundary = trim($quiz->feedbackboundaries[$i]); if (!is_numeric($boundary)) { if (strlen($boundary) > 0 && $boundary[strlen($boundary) - 1] == '%') { $boundary = trim(substr($boundary, 0, -1)); if (is_numeric($boundary)) { $boundary = $boundary * $quiz->grade / 100.0; } else { return get_string('feedbackerrorboundaryformat', 'quiz', $i + 1); } } } if ($boundary <= 0 || $boundary >= $quiz->grade) { return get_string('feedbackerrorboundaryoutofrange', 'quiz', $i + 1); } if ($i > 0 && $boundary >= $quiz->feedbackboundaries[$i - 1]) { return get_string('feedbackerrororder', 'quiz', $i + 1); } $quiz->feedbackboundaries[$i] = $boundary; $i += 1; } $numboundaries = $i; // Check there is nothing in the remaining unused fields. if (!empty($quiz->feedbackboundaries)) { for ($i = $numboundaries; $i < count($quiz->feedbackboundaries); $i += 1) { if (!empty($quiz->feedbackboundaries[$i]) && trim($quiz->feedbackboundaries[$i]) != '') { return get_string('feedbackerrorjunkinboundary', 'quiz', $i + 1); } } } for ($i = $numboundaries + 1; $i < count($quiz->feedbacktext); $i += 1) { if (!empty($quiz->feedbacktext[$i]['text']) && trim($quiz->feedbacktext[$i]['text']) != '') { return get_string('feedbackerrorjunkinfeedback', 'quiz', $i + 1); } } // Needs to be bigger than $quiz->grade because of '<' test in quiz_feedback_for_grade(). $quiz->feedbackboundaries[-1] = $quiz->grade + 1; $quiz->feedbackboundaries[$numboundaries] = 0; $quiz->feedbackboundarycount = $numboundaries; } else { $quiz->feedbackboundarycount = -1; } // Combing the individual settings into the review columns. $quiz->reviewattempt = quiz_review_option_form_to_db($quiz, 'attempt'); $quiz->reviewcorrectness = quiz_review_option_form_to_db($quiz, 'correctness'); $quiz->reviewmaxmarks = quiz_review_option_form_to_db($quiz, 'maxmarks'); $quiz->reviewmarks = quiz_review_option_form_to_db($quiz, 'marks'); $quiz->reviewspecificfeedback = quiz_review_option_form_to_db($quiz, 'specificfeedback'); $quiz->reviewgeneralfeedback = quiz_review_option_form_to_db($quiz, 'generalfeedback'); $quiz->reviewrightanswer = quiz_review_option_form_to_db($quiz, 'rightanswer'); $quiz->reviewoverallfeedback = quiz_review_option_form_to_db($quiz, 'overallfeedback'); $quiz->reviewattempt |= display_options::DURING; $quiz->reviewoverallfeedback &= ~display_options::DURING; // Ensure that disabled checkboxes in completion settings are set to 0. // But only if the completion settinsg are unlocked. if (!empty($quiz->completionunlocked)) { if (empty($quiz->completionusegrade)) { $quiz->completionpassgrade = 0; } if (empty($quiz->completionpassgrade)) { $quiz->completionattemptsexhausted = 0; } if (empty($quiz->completionminattemptsenabled)) { $quiz->completionminattempts = 0; } } } /** * Helper function for {@link quiz_process_options()}. * @param stdClass $fromform the sumbitted form date. * @param string $field one of the review option field names. */ function quiz_review_option_form_to_db($fromform, $field) { static $times = [ 'during' => display_options::DURING, 'immediately' => display_options::IMMEDIATELY_AFTER, 'open' => display_options::LATER_WHILE_OPEN, 'closed' => display_options::AFTER_CLOSE, ]; $review = 0; foreach ($times as $whenname => $when) { $fieldname = $field . $whenname; if (!empty($fromform->$fieldname)) { $review |= $when; unset($fromform->$fieldname); } } return $review; } /** * In place editable callback for slot displaynumber. * * @param string $itemtype slotdisplarnumber * @param int $itemid the id of the slot in the quiz_slots table * @param string $newvalue the new value for displaynumber field for a given slot in the quiz_slots table * @return \core\output\inplace_editable|void */ function mod_quiz_inplace_editable(string $itemtype, int $itemid, string $newvalue): \core\output\inplace_editable { global $DB; if ($itemtype === 'slotdisplaynumber') { // Work out which quiz and slot this is. $slot = $DB->get_record('quiz_slots', ['id' => $itemid], '*', MUST_EXIST); $quizobj = quiz_settings::create($slot->quizid); // Validate the context, and check the required capability. $context = $quizobj->get_context(); \core_external\external_api::validate_context($context); require_capability('mod/quiz:manage', $context); // Update the value - truncating the size of the DB column. $structure = $quizobj->get_structure(); $structure->update_slot_display_number($itemid, core_text::substr($newvalue, 0, 16)); // Prepare the element for the output. return $structure->make_slot_display_number_in_place_editable($itemid, $context); } } /** * This function is called at the end of quiz_add_instance * and quiz_update_instance, to do the common processing. * * @param stdClass $quiz the quiz object. */ function quiz_after_add_or_update($quiz) { global $DB; $cmid = $quiz->coursemodule; // We need to use context now, so we need to make sure all needed info is already in db. $DB->set_field('course_modules', 'instance', $quiz->id, ['id' => $cmid]); $context = context_module::instance($cmid); // Save the feedback. $DB->delete_records('quiz_feedback', ['quizid' => $quiz->id]); for ($i = 0; $i <= $quiz->feedbackboundarycount; $i++) { $feedback = new stdClass(); $feedback->quizid = $quiz->id; $feedback->feedbacktext = $quiz->feedbacktext[$i]['text']; $feedback->feedbacktextformat = $quiz->feedbacktext[$i]['format']; $feedback->mingrade = $quiz->feedbackboundaries[$i]; $feedback->maxgrade = $quiz->feedbackboundaries[$i - 1]; $feedback->id = $DB->insert_record('quiz_feedback', $feedback); $feedbacktext = file_save_draft_area_files((int)$quiz->feedbacktext[$i]['itemid'], $context->id, 'mod_quiz', 'feedback', $feedback->id, ['subdirs' => false, 'maxfiles' => -1, 'maxbytes' => 0], $quiz->feedbacktext[$i]['text']); $DB->set_field('quiz_feedback', 'feedbacktext', $feedbacktext, ['id' => $feedback->id]); } // Store any settings belonging to the access rules. access_manager::save_settings($quiz); // Update the events relating to this quiz. quiz_update_events($quiz); $completionexpected = (!empty($quiz->completionexpected)) ? $quiz->completionexpected : null; \core_completion\api::update_completion_date_event($quiz->coursemodule, 'quiz', $quiz->id, $completionexpected); // Update related grade item. quiz_grade_item_update($quiz); } /** * This function updates the events associated to the quiz. * If $override is non-zero, then it updates only the events * associated with the specified override. * * @param stdClass $quiz the quiz object. * @param stdClass|null $override limit to a specific override */ function quiz_update_events($quiz, $override = null) { global $DB; // Load the old events relating to this quiz. $conds = ['modulename' => 'quiz', 'instance' => $quiz->id]; if (!empty($override)) { // Only load events for this override. if (isset($override->userid)) { $conds['userid'] = $override->userid; } else { $conds['groupid'] = $override->groupid; } } $oldevents = $DB->get_records('event', $conds, 'id ASC'); // Now make a to-do list of all that needs to be updated. if (empty($override)) { // We are updating the primary settings for the quiz, so we need to add all the overrides. $overrides = $DB->get_records('quiz_overrides', ['quiz' => $quiz->id], 'id ASC'); // It is necessary to add an empty stdClass to the beginning of the array as the $oldevents // list contains the original (non-override) event for the module. If this is not included // the logic below will end up updating the wrong row when we try to reconcile this $overrides // list against the $oldevents list. array_unshift($overrides, new stdClass()); } else { // Just do the one override. $overrides = [$override]; } // Get group override priorities. $grouppriorities = quiz_get_group_override_priorities($quiz->id); foreach ($overrides as $current) { $groupid = isset($current->groupid)? $current->groupid : 0; $userid = isset($current->userid)? $current->userid : 0; $timeopen = isset($current->timeopen)? $current->timeopen : $quiz->timeopen; $timeclose = isset($current->timeclose)? $current->timeclose : $quiz->timeclose; // Only add open/close events for an override if they differ from the quiz default. $addopen = empty($current->id) || !empty($current->timeopen); $addclose = empty($current->id) || !empty($current->timeclose); if (!empty($quiz->coursemodule)) { $cmid = $quiz->coursemodule; } else { $cmid = get_coursemodule_from_instance('quiz', $quiz->id, $quiz->course)->id; } $event = new stdClass(); $event->type = !$timeclose ? CALENDAR_EVENT_TYPE_ACTION : CALENDAR_EVENT_TYPE_STANDARD; $event->description = format_module_intro('quiz', $quiz, $cmid, false); $event->format = FORMAT_HTML; // Events module won't show user events when the courseid is nonzero. $event->courseid = ($userid) ? 0 : $quiz->course; $event->groupid = $groupid; $event->userid = $userid; $event->modulename = 'quiz'; $event->instance = $quiz->id; $event->timestart = $timeopen; $event->timeduration = max($timeclose - $timeopen, 0); $event->timesort = $timeopen; $event->visible = instance_is_visible('quiz', $quiz); $event->eventtype = QUIZ_EVENT_TYPE_OPEN; $event->priority = null; // Determine the event name and priority. if ($groupid) { // Group override event. $params = new stdClass(); $params->quiz = $quiz->name; $params->group = groups_get_group_name($groupid); if ($params->group === false) { // Group doesn't exist, just skip it. continue; } $eventname = get_string('overridegroupeventname', 'quiz', $params); // Set group override priority. if ($grouppriorities !== null) { $openpriorities = $grouppriorities['open']; if (isset($openpriorities[$timeopen])) { $event->priority = $openpriorities[$timeopen]; } } } else if ($userid) { // User override event. $params = new stdClass(); $params->quiz = $quiz->name; $eventname = get_string('overrideusereventname', 'quiz', $params); // Set user override priority. $event->priority = CALENDAR_EVENT_USER_OVERRIDE_PRIORITY; } else { // The parent event. $eventname = $quiz->name; } if ($addopen or $addclose) { // Separate start and end events. $event->timeduration = 0; if ($timeopen && $addopen) { if ($oldevent = array_shift($oldevents)) { $event->id = $oldevent->id; } else { unset($event->id); } $event->name = get_string('quizeventopens', 'quiz', $eventname); // The method calendar_event::create will reuse a db record if the id field is set. calendar_event::create($event, false); } if ($timeclose && $addclose) { if ($oldevent = array_shift($oldevents)) { $event->id = $oldevent->id; } else { unset($event->id); } $event->type = CALENDAR_EVENT_TYPE_ACTION; $event->name = get_string('quizeventcloses', 'quiz', $eventname); $event->timestart = $timeclose; $event->timesort = $timeclose; $event->eventtype = QUIZ_EVENT_TYPE_CLOSE; if ($groupid && $grouppriorities !== null) { $closepriorities = $grouppriorities['close']; if (isset($closepriorities[$timeclose])) { $event->priority = $closepriorities[$timeclose]; } } calendar_event::create($event, false); } } } // Delete any leftover events. foreach ($oldevents as $badevent) { $badevent = calendar_event::load($badevent); $badevent->delete(); } } /** * Calculates the priorities of timeopen and timeclose values for group overrides for a quiz. * * @param int $quizid The quiz ID. * @return array|null Array of group override priorities for open and close times. Null if there are no group overrides. */ function quiz_get_group_override_priorities($quizid) { global $DB; // Fetch group overrides. $where = 'quiz = :quiz AND groupid IS NOT NULL'; $params = ['quiz' => $quizid]; $overrides = $DB->get_records_select('quiz_overrides', $where, $params, '', 'id, timeopen, timeclose'); if (!$overrides) { return null; } $grouptimeopen = []; $grouptimeclose = []; foreach ($overrides as $override) { if ($override->timeopen !== null && !in_array($override->timeopen, $grouptimeopen)) { $grouptimeopen[] = $override->timeopen; } if ($override->timeclose !== null && !in_array($override->timeclose, $grouptimeclose)) { $grouptimeclose[] = $override->timeclose; } } // Sort open times in ascending manner. The earlier open time gets higher priority. sort($grouptimeopen); // Set priorities. $opengrouppriorities = []; $openpriority = 1; foreach ($grouptimeopen as $timeopen) { $opengrouppriorities[$timeopen] = $openpriority++; } // Sort close times in descending manner. The later close time gets higher priority. rsort($grouptimeclose); // Set priorities. $closegrouppriorities = []; $closepriority = 1; foreach ($grouptimeclose as $timeclose) { $closegrouppriorities[$timeclose] = $closepriority++; } return [ 'open' => $opengrouppriorities, 'close' => $closegrouppriorities ]; } /** * List the actions that correspond to a view of this module. * This is used by the participation report. * * Note: This is not used by new logging system. Event with * crud = 'r' and edulevel = LEVEL_PARTICIPATING will * be considered as view action. * * @return array */ function quiz_get_view_actions() { return ['view', 'view all', 'report', 'review']; } /** * List the actions that correspond to a post of this module. * This is used by the participation report. * * Note: This is not used by new logging system. Event with * crud = ('c' || 'u' || 'd') and edulevel = LEVEL_PARTICIPATING * will be considered as post action. * * @return array */ function quiz_get_post_actions() { return ['attempt', 'close attempt', 'preview', 'editquestions', 'delete attempt', 'manualgrade']; } /** * Standard callback used by questions_in_use. * * @param array $questionids of question ids. * @return bool whether any of these questions are used by any instance of this module. */ function quiz_questions_in_use($questionids) { return question_engine::questions_in_use($questionids, new qubaid_join('{quiz_attempts} quiza', 'quiza.uniqueid', 'quiza.preview = 0')); } /** * Implementation of the function for printing the form elements that control * whether the course reset functionality affects the quiz. * * @param MoodleQuickForm $mform the course reset form that is being built. */ function quiz_reset_course_form_definition($mform) { $mform->addElement('header', 'quizheader', get_string('modulenameplural', 'quiz')); $mform->addElement('static', 'quizdelete', get_string('delete')); $mform->addElement('advcheckbox', 'reset_quiz_attempts', get_string('removeallquizattempts', 'quiz')); $mform->addElement('advcheckbox', 'reset_quiz_user_overrides', get_string('removealluseroverrides', 'quiz')); $mform->addElement('advcheckbox', 'reset_quiz_group_overrides', get_string('removeallgroupoverrides', 'quiz')); } /** * Course reset form defaults. * @return array the defaults. */ function quiz_reset_course_form_defaults($course) { return ['reset_quiz_attempts' => 1, 'reset_quiz_group_overrides' => 1, 'reset_quiz_user_overrides' => 1]; } /** * Removes all grades from gradebook * * @param int $courseid * @param string optional type */ function quiz_reset_gradebook($courseid, $type='') { global $CFG, $DB; $quizzes = $DB->get_records_sql(" SELECT q.*, cm.idnumber as cmidnumber, q.course as courseid FROM {modules} m JOIN {course_modules} cm ON m.id = cm.module JOIN {quiz} q ON cm.instance = q.id WHERE m.name = 'quiz' AND cm.course = ?", [$courseid]); foreach ($quizzes as $quiz) { quiz_grade_item_update($quiz, 'reset'); } } /** * Actual implementation of the reset course functionality, delete all the * quiz attempts for course $data->courseid, if $data->reset_quiz_attempts is * set and true. * * Also, move the quiz open and close dates, if the course start date is changing. * * @param stdClass $data the data submitted from the reset course. * @return array status array */ function quiz_reset_userdata($data) { global $CFG, $DB; require_once($CFG->libdir . '/questionlib.php'); $componentstr = get_string('modulenameplural', 'quiz'); $status = []; // Delete attempts. if (!empty($data->reset_quiz_attempts)) { question_engine::delete_questions_usage_by_activities(new qubaid_join( '{quiz_attempts} quiza JOIN {quiz} quiz ON quiza.quiz = quiz.id', 'quiza.uniqueid', 'quiz.course = :quizcourseid', ['quizcourseid' => $data->courseid])); $DB->delete_records_select('quiz_attempts', 'quiz IN (SELECT id FROM {quiz} WHERE course = ?)', [$data->courseid]); $status[] = [ 'component' => $componentstr, 'item' => get_string('removeallquizattempts', 'quiz'), 'error' => false]; // Remove all grades from gradebook. $DB->delete_records_select('quiz_grades', 'quiz IN (SELECT id FROM {quiz} WHERE course = ?)', [$data->courseid]); if (empty($data->reset_gradebook_grades)) { quiz_reset_gradebook($data->courseid); } $status[] = [ 'component' => $componentstr, 'item' => get_string('grades'), 'error' => false]; } $purgeoverrides = false; // Remove user overrides. if (!empty($data->reset_quiz_user_overrides)) { $DB->delete_records_select('quiz_overrides', 'quiz IN (SELECT id FROM {quiz} WHERE course = ?) AND userid IS NOT NULL', [$data->courseid]); $status[] = [ 'component' => $componentstr, 'item' => get_string('useroverrides', 'quiz'), 'error' => false]; $purgeoverrides = true; } // Remove group overrides. if (!empty($data->reset_quiz_group_overrides)) { $DB->delete_records_select('quiz_overrides', 'quiz IN (SELECT id FROM {quiz} WHERE course = ?) AND groupid IS NOT NULL', [$data->courseid]); $status[] = [ 'component' => $componentstr, 'item' => get_string('groupoverrides', 'quiz'), 'error' => false]; $purgeoverrides = true; } // Updating dates - shift may be negative too. if ($data->timeshift) { $DB->execute("UPDATE {quiz_overrides} SET timeopen = timeopen + ? WHERE quiz IN (SELECT id FROM {quiz} WHERE course = ?) AND timeopen <> 0", [$data->timeshift, $data->courseid]); $DB->execute("UPDATE {quiz_overrides} SET timeclose = timeclose + ? WHERE quiz IN (SELECT id FROM {quiz} WHERE course = ?) AND timeclose <> 0", [$data->timeshift, $data->courseid]); $purgeoverrides = true; // Any changes to the list of dates that needs to be rolled should be same during course restore and course reset. // See MDL-9367. shift_course_mod_dates('quiz', ['timeopen', 'timeclose'], $data->timeshift, $data->courseid); $status[] = [ 'component' => $componentstr, 'item' => get_string('openclosedatesupdated', 'quiz'), 'error' => false]; } if ($purgeoverrides) { \cache_helper::purge_by_event(\mod_quiz\local\override_cache::INVALIDATION_USERDATARESET); } return $status; } /** * Return a textual summary of the number of attempts that have been made at a particular quiz, * returns '' if no attempts have been made yet, unless $returnzero is passed as true. * * @param stdClass $quiz the quiz object. Only $quiz->id is used at the moment. * @param stdClass $cm the cm object. Only $cm->course, $cm->groupmode and * $cm->groupingid fields are used at the moment. * @param bool $returnzero if false (default), when no attempts have been * made '' is returned instead of 'Attempts: 0'. * @param int $currentgroup if there is a concept of current group where this method is being called * (e.g. a report) pass it in here. Default 0 which means no current group. * @return string a string like "Attempts: 123", "Attemtps 123 (45 from your groups)" or * "Attemtps 123 (45 from this group)". */ function quiz_num_attempt_summary($quiz, $cm, $returnzero = false, $currentgroup = 0) { global $DB, $USER; $numattempts = $DB->count_records('quiz_attempts', ['quiz' => $quiz->id, 'preview' => 0]); if ($numattempts || $returnzero) { if (groups_get_activity_groupmode($cm)) { $a = new stdClass(); $a->total = $numattempts; if ($currentgroup) { $a->group = $DB->count_records_sql('SELECT COUNT(DISTINCT qa.id) FROM ' . '{quiz_attempts} qa JOIN ' . '{groups_members} gm ON qa.userid = gm.userid ' . 'WHERE quiz = ? AND preview = 0 AND groupid = ?', [$quiz->id, $currentgroup]); return get_string('attemptsnumthisgroup', 'quiz', $a); } else if ($groups = groups_get_all_groups($cm->course, $USER->id, $cm->groupingid)) { [$usql, $params] = $DB->get_in_or_equal(array_keys($groups)); $a->group = $DB->count_records_sql('SELECT COUNT(DISTINCT qa.id) FROM ' . '{quiz_attempts} qa JOIN ' . '{groups_members} gm ON qa.userid = gm.userid ' . 'WHERE quiz = ? AND preview = 0 AND ' . "groupid $usql", array_merge([$quiz->id], $params)); return get_string('attemptsnumyourgroups', 'quiz', $a); } } return get_string('attemptsnum', 'quiz', $numattempts); } return ''; } /** * Returns the same as {@link quiz_num_attempt_summary()} but wrapped in a link * to the quiz reports. * * @param stdClass $quiz the quiz object. Only $quiz->id is used at the moment. * @param stdClass $cm the cm object. Only $cm->course, $cm->groupmode and * $cm->groupingid fields are used at the moment. * @param stdClass $context the quiz context. * @param bool $returnzero if false (default), when no attempts have been made * '' is returned instead of 'Attempts: 0'. * @param int $currentgroup if there is a concept of current group where this method is being called * (e.g. a report) pass it in here. Default 0 which means no current group. * @return string HTML fragment for the link. */ function quiz_attempt_summary_link_to_reports($quiz, $cm, $context, $returnzero = false, $currentgroup = 0) { global $PAGE; return $PAGE->get_renderer('mod_quiz')->quiz_attempt_summary_link_to_reports( $quiz, $cm, $context, $returnzero, $currentgroup); } /** * @param string $feature FEATURE_xx constant for requested feature * @return mixed True if module supports feature, false if not, null if doesn't know or string for the module purpose. */ function quiz_supports($feature) { switch($feature) { case FEATURE_GROUPS: return true; case FEATURE_GROUPINGS: return true; case FEATURE_MOD_INTRO: return true; case FEATURE_COMPLETION_TRACKS_VIEWS: return true; case FEATURE_COMPLETION_HAS_RULES: return true; case FEATURE_GRADE_HAS_GRADE: return true; case FEATURE_GRADE_OUTCOMES: return true; case FEATURE_BACKUP_MOODLE2: return true; case FEATURE_SHOW_DESCRIPTION: return true; case FEATURE_CONTROLS_GRADE_VISIBILITY: return true; case FEATURE_USES_QUESTIONS: return true; case FEATURE_PLAGIARISM: return true; case FEATURE_MOD_PURPOSE: return MOD_PURPOSE_ASSESSMENT; default: return null; } } /** * @return array all other caps used in module */ function quiz_get_extra_capabilities() { global $CFG; require_once($CFG->libdir . '/questionlib.php'); return question_get_all_capabilities(); } /** * This function extends the settings navigation block for the site. * * It is safe to rely on PAGE here as we will only ever be within the module * context when this is called * * @param settings_navigation $settings * @param navigation_node $quiznode * @return void */ function quiz_extend_settings_navigation(settings_navigation $settings, navigation_node $quiznode) { global $CFG; // Require {@link questionlib.php} // Included here as we only ever want to include this file if we really need to. require_once($CFG->libdir . '/questionlib.php'); // We want to add these new nodes after the Edit settings node, and before the // Locally assigned roles node. Of course, both of those are controlled by capabilities. $keys = $quiznode->get_children_key_list(); $beforekey = null; $i = array_search('modedit', $keys); if ($i === false and array_key_exists(0, $keys)) { $beforekey = $keys[0]; } else if (array_key_exists($i + 1, $keys)) { $beforekey = $keys[$i + 1]; } if (has_any_capability(['mod/quiz:manageoverrides', 'mod/quiz:viewoverrides'], $settings->get_page()->cm->context)) { $url = new moodle_url('/mod/quiz/overrides.php', ['cmid' => $settings->get_page()->cm->id, 'mode' => 'user']); $node = navigation_node::create(get_string('overrides', 'quiz'), $url, navigation_node::TYPE_SETTING, null, 'mod_quiz_useroverrides'); $settingsoverride = $quiznode->add_node($node, $beforekey); } if (has_capability('mod/quiz:manage', $settings->get_page()->cm->context)) { $node = navigation_node::create(get_string('questions', 'quiz'), new moodle_url('/mod/quiz/edit.php', ['cmid' => $settings->get_page()->cm->id]), navigation_node::TYPE_SETTING, null, 'mod_quiz_edit', new pix_icon('t/edit', '')); $quiznode->add_node($node, $beforekey); } if (has_capability('mod/quiz:preview', $settings->get_page()->cm->context)) { $url = new moodle_url('/mod/quiz/startattempt.php', ['cmid' => $settings->get_page()->cm->id, 'sesskey' => sesskey()]); $node = navigation_node::create(get_string('preview', 'quiz'), $url, navigation_node::TYPE_SETTING, null, 'mod_quiz_preview', new pix_icon('i/preview', '')); $previewnode = $quiznode->add_node($node, $beforekey); $previewnode->set_show_in_secondary_navigation(false); } question_extend_settings_navigation($quiznode, $settings->get_page()->cm->context)->trim_if_empty(); if (has_any_capability(['mod/quiz:viewreports', 'mod/quiz:grade'], $settings->get_page()->cm->context)) { require_once($CFG->dirroot . '/mod/quiz/report/reportlib.php'); $reportlist = quiz_report_list($settings->get_page()->cm->context); $url = new moodle_url('/mod/quiz/report.php', ['id' => $settings->get_page()->cm->id, 'mode' => reset($reportlist)]); $reportnode = $quiznode->add_node(navigation_node::create(get_string('results', 'quiz'), $url, navigation_node::TYPE_SETTING, null, 'quiz_report', new pix_icon('i/report', ''))); foreach ($reportlist as $report) { $url = new moodle_url('/mod/quiz/report.php', ['id' => $settings->get_page()->cm->id, 'mode' => $report]); $reportnode->add_node(navigation_node::create(get_string($report, 'quiz_'.$report), $url, navigation_node::TYPE_SETTING, null, 'quiz_report_' . $report, new pix_icon('i/item', ''))); } } } /** * Serves the quiz files. * * @package mod_quiz * @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 false if file not found, does not return if found - justsend the file */ function quiz_pluginfile($course, $cm, $context, $filearea, $args, $forcedownload, array $options= []) { global $CFG, $DB; if ($context->contextlevel != CONTEXT_MODULE) { return false; } require_login($course, false, $cm); if (!$quiz = $DB->get_record('quiz', ['id' => $cm->instance])) { return false; } // The 'intro' area is served by pluginfile.php. $fileareas = ['feedback']; if (!in_array($filearea, $fileareas)) { return false; } $feedbackid = (int)array_shift($args); if (!$feedback = $DB->get_record('quiz_feedback', ['id' => $feedbackid])) { return false; } $fs = get_file_storage(); $relativepath = implode('/', $args); $fullpath = "/$context->id/mod_quiz/$filearea/$feedbackid/$relativepath"; if (!$file = $fs->get_file_by_hash(sha1($fullpath)) or $file->is_directory()) { return false; } send_stored_file($file, 0, 0, true, $options); } /** * Called via pluginfile.php -> question_pluginfile to serve files belonging to * a question in a question_attempt when that attempt is a quiz attempt. * * @package mod_quiz * @category files * @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 attempt usage id. * @param int $slot the id of a question in this quiz attempt. * @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 * @return bool false if file not found, does not return if found - justsend the file */ function quiz_question_pluginfile($course, $context, $component, $filearea, $qubaid, $slot, $args, $forcedownload, array $options= []) { global $CFG; require_once($CFG->dirroot . '/mod/quiz/locallib.php'); $attemptobj = quiz_attempt::create_from_usage_id($qubaid); require_login($attemptobj->get_course(), false, $attemptobj->get_cm()); if ($attemptobj->is_own_attempt() && !$attemptobj->is_finished()) { // In the middle of an attempt. if (!$attemptobj->is_preview_user()) { $attemptobj->require_capability('mod/quiz:attempt'); } $isreviewing = false; } else { // Reviewing an attempt. $attemptobj->check_review_capability(); $isreviewing = true; } if (!$attemptobj->check_file_access($slot, $isreviewing, $context->id, $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, $options); } /** * Return a list of page types * @param string $pagetype current page type * @param stdClass $parentcontext Block's parent context * @param stdClass $currentcontext Current context of block */ function quiz_page_type_list($pagetype, $parentcontext, $currentcontext) { $modulepagetype = [ 'mod-quiz-*' => get_string('page-mod-quiz-x', 'quiz'), 'mod-quiz-view' => get_string('page-mod-quiz-view', 'quiz'), 'mod-quiz-attempt' => get_string('page-mod-quiz-attempt', 'quiz'), 'mod-quiz-summary' => get_string('page-mod-quiz-summary', 'quiz'), 'mod-quiz-review' => get_string('page-mod-quiz-review', 'quiz'), 'mod-quiz-edit' => get_string('page-mod-quiz-edit', 'quiz'), 'mod-quiz-report' => get_string('page-mod-quiz-report', 'quiz'), ]; return $modulepagetype; } /** * @return the options for quiz navigation. */ function quiz_get_navigation_options() { return [ QUIZ_NAVMETHOD_FREE => get_string('navmethod_free', 'quiz'), QUIZ_NAVMETHOD_SEQ => get_string('navmethod_seq', 'quiz') ]; } /** * Check if the module has any update that affects the current user since a given time. * * @param cm_info $cm course module data * @param int $from the time to check updates from * @param array $filter if we need to check only specific updates * @return stdClass an object with the different type of areas indicating if they were updated or not * @since Moodle 3.2 */ function quiz_check_updates_since(cm_info $cm, $from, $filter = []) { global $DB, $USER, $CFG; require_once($CFG->dirroot . '/mod/quiz/locallib.php'); $updates = course_check_module_updates_since($cm, $from, [], $filter); // Check if questions were updated. $updates->questions = (object) ['updated' => false]; $quizobj = quiz_settings::create($cm->instance, $USER->id); $quizobj->preload_questions(); $questionids = array_keys($quizobj->get_questions(null, false)); if (!empty($questionids)) { [$questionsql, $params] = $DB->get_in_or_equal($questionids, SQL_PARAMS_NAMED); $select = 'id ' . $questionsql . ' AND (timemodified > :time1 OR timecreated > :time2)'; $params['time1'] = $from; $params['time2'] = $from; $questions = $DB->get_records_select('question', $select, $params, '', 'id'); if (!empty($questions)) { $updates->questions->updated = true; $updates->questions->itemids = array_keys($questions); } } // Check for new attempts or grades. $updates->attempts = (object) ['updated' => false]; $updates->grades = (object) ['updated' => false]; $select = 'quiz = ? AND userid = ? AND timemodified > ?'; $params = [$cm->instance, $USER->id, $from]; $attempts = $DB->get_records_select('quiz_attempts', $select, $params, '', 'id'); if (!empty($attempts)) { $updates->attempts->updated = true; $updates->attempts->itemids = array_keys($attempts); } $grades = $DB->get_records_select('quiz_grades', $select, $params, '', 'id'); if (!empty($grades)) { $updates->grades->updated = true; $updates->grades->itemids = array_keys($grades); } // Now, teachers should see other students updates. if (has_capability('mod/quiz:viewreports', $cm->context)) { $select = 'quiz = ? AND timemodified > ?'; $params = [$cm->instance, $from]; if (groups_get_activity_groupmode($cm) == SEPARATEGROUPS) { $groupusers = array_keys(groups_get_activity_shared_group_members($cm)); if (empty($groupusers)) { return $updates; } [$insql, $inparams] = $DB->get_in_or_equal($groupusers); $select .= ' AND userid ' . $insql; $params = array_merge($params, $inparams); } $updates->userattempts = (object) ['updated' => false]; $attempts = $DB->get_records_select('quiz_attempts', $select, $params, '', 'id'); if (!empty($attempts)) { $updates->userattempts->updated = true; $updates->userattempts->itemids = array_keys($attempts); } $updates->usergrades = (object) ['updated' => false]; $grades = $DB->get_records_select('quiz_grades', $select, $params, '', 'id'); if (!empty($grades)) { $updates->usergrades->updated = true; $updates->usergrades->itemids = array_keys($grades); } } return $updates; } /** * Get icon mapping for font-awesome. */ function mod_quiz_get_fontawesome_icon_map() { return [ 'mod_quiz:navflagged' => 'fa-flag', ]; } /** * This function receives a calendar event and returns the action associated with it, or null if there is none. * * This is used by block_myoverview in order to display the event appropriately. If null is returned then the event * is not displayed on the block. * * @param calendar_event $event * @param \core_calendar\action_factory $factory * @param int $userid User id to use for all capability checks, etc. Set to 0 for current user (default). * @return \core_calendar\local\event\entities\action_interface|null */ function mod_quiz_core_calendar_provide_event_action(calendar_event $event, \core_calendar\action_factory $factory, int $userid = 0) { global $CFG, $USER; require_once($CFG->dirroot . '/mod/quiz/locallib.php'); if (empty($userid)) { $userid = $USER->id; } $cm = get_fast_modinfo($event->courseid, $userid)->instances['quiz'][$event->instance]; $quizobj = quiz_settings::create($cm->instance, $userid); $quiz = $quizobj->get_quiz(); // Check they have capabilities allowing them to view the quiz. if (!has_any_capability(['mod/quiz:reviewmyattempts', 'mod/quiz:attempt'], $quizobj->get_context(), $userid)) { return null; } $completion = new \completion_info($cm->get_course()); $completiondata = $completion->get_data($cm, false, $userid); if ($completiondata->completionstate != COMPLETION_INCOMPLETE) { return null; } quiz_update_effective_access($quiz, $userid); // Check if quiz is closed, if so don't display it. if (!empty($quiz->timeclose) && $quiz->timeclose <= time()) { return null; } if (!$quizobj->is_participant($userid)) { // If the user is not a participant then they have // no action to take. This will filter out the events for teachers. return null; } $attempts = quiz_get_user_attempts($quizobj->get_quizid(), $userid); if (!empty($attempts)) { // The student's last attempt is finished. return null; } $name = get_string('attemptquiznow', 'quiz'); $url = new \moodle_url('/mod/quiz/view.php', [ 'id' => $cm->id ]); $itemcount = 1; $actionable = true; // Check if the quiz is not currently actionable. if (!empty($quiz->timeopen) && $quiz->timeopen > time()) { $actionable = false; } return $factory->create_instance( $name, $url, $itemcount, $actionable ); } /** * Add a get_coursemodule_info function in case any quiz type wants to add 'extra' information * for the course (see resource). * * Given a course_module object, this function returns any "extra" information that may be needed * when printing this activity in a course listing. See get_array_of_activities() in course/lib.php. * * @param stdClass $coursemodule The coursemodule object (record). * @return cached_cm_info|false An object on information that the courses * will know about (most noticeably, an icon). */ function quiz_get_coursemodule_info($coursemodule) { global $DB; $dbparams = ['id' => $coursemodule->instance]; $fields = 'id, name, intro, introformat, completionattemptsexhausted, completionminattempts, timeopen, timeclose'; if (!$quiz = $DB->get_record('quiz', $dbparams, $fields)) { return false; } $result = new cached_cm_info(); $result->name = $quiz->name; if ($coursemodule->showdescription) { // Convert intro to html. Do not filter cached version, filters run at display time. $result->content = format_module_intro('quiz', $quiz, $coursemodule->id, false); } // Populate the custom completion rules as key => value pairs, but only if the completion mode is 'automatic'. if ($coursemodule->completion == COMPLETION_TRACKING_AUTOMATIC) { if ($quiz->completionattemptsexhausted) { $result->customdata['customcompletionrules']['completionpassorattemptsexhausted'] = [ 'completionpassgrade' => $coursemodule->completionpassgrade, 'completionattemptsexhausted' => $quiz->completionattemptsexhausted, ]; } else { $result->customdata['customcompletionrules']['completionpassorattemptsexhausted'] = []; } $result->customdata['customcompletionrules']['completionminattempts'] = $quiz->completionminattempts; } // Populate some other values that can be used in calendar or on dashboard. if ($quiz->timeopen) { $result->customdata['timeopen'] = $quiz->timeopen; } if ($quiz->timeclose) { $result->customdata['timeclose'] = $quiz->timeclose; } return $result; } /** * Sets dynamic information about a course module * * This function is called from cm_info when displaying the module * * @param cm_info $cm */ function mod_quiz_cm_info_dynamic(cm_info $cm) { global $USER; $cache = new override_cache($cm->instance); $override = $cache->get_cached_user_override($USER->id); if (!$override) { $override = (object) [ 'timeopen' => null, 'timeclose' => null, ]; } // No need to look for group overrides if there are user overrides for both timeopen and timeclose. if (is_null($override->timeopen) || is_null($override->timeclose)) { $opens = []; $closes = []; $groupings = groups_get_user_groups($cm->course, $USER->id); foreach ($groupings[0] as $groupid) { $groupoverride = $cache->get_cached_group_override($groupid); if (isset($groupoverride->timeopen)) { $opens[] = $groupoverride->timeopen; } if (isset($groupoverride->timeclose)) { $closes[] = $groupoverride->timeclose; } } // If there is a user override for a setting, ignore the group override. if (is_null($override->timeopen) && count($opens)) { $override->timeopen = min($opens); } if (is_null($override->timeclose) && count($closes)) { if (in_array(0, $closes)) { $override->timeclose = 0; } else { $override->timeclose = max($closes); } } } // Populate some other values that can be used in calendar or on dashboard. if (!is_null($override->timeopen)) { $cm->override_customdata('timeopen', $override->timeopen); } if (!is_null($override->timeclose)) { $cm->override_customdata('timeclose', $override->timeclose); } } /** * Callback which returns human-readable strings describing the active completion custom rules for the module instance. * * @param cm_info|stdClass $cm object with fields ->completion and ->customdata['customcompletionrules'] * @return array $descriptions the array of descriptions for the custom rules. */ function mod_quiz_get_completion_active_rule_descriptions($cm) { // Values will be present in cm_info, and we assume these are up to date. if (empty($cm->customdata['customcompletionrules']) || $cm->completion != COMPLETION_TRACKING_AUTOMATIC) { return []; } $descriptions = []; $rules = $cm->customdata['customcompletionrules']; if (!empty($rules['completionpassorattemptsexhausted'])) { if (!empty($rules['completionpassorattemptsexhausted']['completionattemptsexhausted'])) { $descriptions[] = get_string('completionpassorattemptsexhausteddesc', 'quiz'); } } else { // Fallback. if (!empty($rules['completionattemptsexhausted'])) { $descriptions[] = get_string('completionpassorattemptsexhausteddesc', 'quiz'); } } if (!empty($rules['completionminattempts'])) { $descriptions[] = get_string('completionminattemptsdesc', 'quiz', $rules['completionminattempts']); } return $descriptions; } /** * Returns the min and max values for the timestart property of a quiz * activity event. * * The min and max values will be the timeopen and timeclose properties * of the quiz, respectively, if they are set. * * If either value isn't set then null will be returned instead to * indicate that there is no cutoff for that value. * * If the vent has no valid timestart range then [false, false] will * be returned. This is the case for overriden events. * * A minimum and maximum cutoff return value will look like: * [ * [1505704373, 'The date must be after this date'], * [1506741172, 'The date must be before this date'] * ] * * @throws \moodle_exception * @param \calendar_event $event The calendar event to get the time range for * @param stdClass $quiz The module instance to get the range from * @return array */ function mod_quiz_core_calendar_get_valid_event_timestart_range(\calendar_event $event, \stdClass $quiz) { global $CFG, $DB; require_once($CFG->dirroot . '/mod/quiz/locallib.php'); // Overrides do not have a valid timestart range. if (quiz_is_overriden_calendar_event($event)) { return [false, false]; } $mindate = null; $maxdate = null; if ($event->eventtype == QUIZ_EVENT_TYPE_OPEN) { if (!empty($quiz->timeclose)) { $maxdate = [ $quiz->timeclose, get_string('openafterclose', 'quiz') ]; } } else if ($event->eventtype == QUIZ_EVENT_TYPE_CLOSE) { if (!empty($quiz->timeopen)) { $mindate = [ $quiz->timeopen, get_string('closebeforeopen', 'quiz') ]; } } return [$mindate, $maxdate]; } /** * This function will update the quiz module according to the * event that has been modified. * * It will set the timeopen or timeclose value of the quiz instance * according to the type of event provided. * * @throws \moodle_exception * @param \calendar_event $event A quiz activity calendar event * @param \stdClass $quiz A quiz activity instance */ function mod_quiz_core_calendar_event_timestart_updated(\calendar_event $event, \stdClass $quiz) { global $CFG, $DB; require_once($CFG->dirroot . '/mod/quiz/locallib.php'); if (!in_array($event->eventtype, [QUIZ_EVENT_TYPE_OPEN, QUIZ_EVENT_TYPE_CLOSE])) { // This isn't an event that we care about so we can ignore it. return; } $courseid = $event->courseid; $modulename = $event->modulename; $instanceid = $event->instance; $modified = false; $closedatechanged = false; // Something weird going on. The event is for a different module so // we should ignore it. if ($modulename != 'quiz') { return; } if ($quiz->id != $instanceid) { // The provided quiz instance doesn't match the event so // there is nothing to do here. return; } // We don't update the activity if it's an override event that has // been modified. if (quiz_is_overriden_calendar_event($event)) { return; } $coursemodule = get_fast_modinfo($courseid)->instances[$modulename][$instanceid]; $context = context_module::instance($coursemodule->id); // The user does not have the capability to modify this activity. if (!has_capability('moodle/course:manageactivities', $context)) { return; } if ($event->eventtype == QUIZ_EVENT_TYPE_OPEN) { // If the event is for the quiz activity opening then we should // set the start time of the quiz activity to be the new start // time of the event. if ($quiz->timeopen != $event->timestart) { $quiz->timeopen = $event->timestart; $modified = true; } } else if ($event->eventtype == QUIZ_EVENT_TYPE_CLOSE) { // If the event is for the quiz activity closing then we should // set the end time of the quiz activity to be the new start // time of the event. if ($quiz->timeclose != $event->timestart) { $quiz->timeclose = $event->timestart; $modified = true; $closedatechanged = true; } } if ($modified) { $quiz->timemodified = time(); $DB->update_record('quiz', $quiz); if ($closedatechanged) { quiz_update_open_attempts(['quizid' => $quiz->id]); } // Delete any previous preview attempts. quiz_delete_previews($quiz); quiz_update_events($quiz); $event = \core\event\course_module_updated::create_from_cm($coursemodule, $context); $event->trigger(); } } /** * Generates the question bank in a fragment output. This allows * the question bank to be displayed in a modal. * * The only expected argument provided in the $args array is * 'querystring'. The value should be the list of parameters * URL encoded and used to build the question bank page. * * The individual list of parameters expected can be found in * question_build_edit_resources. * * @param array $args The fragment arguments. * @return string The rendered mform fragment. */ function mod_quiz_output_fragment_quiz_question_bank($args): string { global $PAGE; // Retrieve params. $params = []; $extraparams = []; $querystring = parse_url($args['querystring'], PHP_URL_QUERY); parse_str($querystring, $params); // Load the bank we are looking at rather than always the quiz module itself. $params['cmid'] = clean_param($args['bankcmid'], PARAM_INT); $viewclass = \mod_quiz\question\bank\custom_view::class; $extraparams['view'] = $viewclass; // We need the quiz modid to POST back to. $extraparams['quizcmid'] = clean_param($args['quizcmid'], PARAM_INT); // Build required parameters. [$contexts, $thispageurl, $cm, $pagevars, $extraparams] = build_required_parameters_for_custom_view($params, $extraparams); $course = get_course($cm->course); if (!has_capability('moodle/question:useall', $contexts->lowest())) { require_capability('moodle/question:usemine', $contexts->lowest()); } // Custom View. $questionbank = new $viewclass($contexts, $thispageurl, $course, $cm, $pagevars, $extraparams); // Output. $renderer = $PAGE->get_renderer('mod_quiz', 'edit'); return $renderer->question_bank_contents($questionbank, $pagevars); } /** * Build and return the output for the question bank and category chooser. * * @param array $args provided by the AJAX request. * @return string html to render to the modal. */ function mod_quiz_output_fragment_switch_question_bank($args): string { global $USER, $COURSE, $OUTPUT; $quizcmid = clean_param($args['quizcmid'], PARAM_INT); $switchbankwidget = new \core_question\output\switch_question_bank($quizcmid, $COURSE->id, $USER->id); return $OUTPUT->render($switchbankwidget); } /** * Generates the add random question in a fragment output. This allows the * form to be rendered in javascript, for example inside a modal. * * The required arguments as keys in the $args array are: * addonpage {int} The page id to add this question to. * returnurl {string} URL to return to after form submission. * quizcmid {int} The quiz course module id the questions are being added to. * bankcmid {int} The question bank course module id the questions are being added from. * * @param array $args The fragment arguments. * @return string The rendered mform fragment. */ function mod_quiz_output_fragment_add_random_question_form($args) { global $PAGE, $OUTPUT, $DB; $slotid = clean_param($args['slotid'] ?? 0, PARAM_INT); if (empty($slotid)) { $params = $args; } else { $contextid = \core\context\module::instance(clean_param($args['quizcmid'], PARAM_INT))->id; // Load the stored filters for the current slot. $setreference = $DB->get_record('question_set_references', [ 'usingcontextid' => $contextid, 'component' => 'mod_quiz', 'questionarea' => 'slot', 'itemid' => $slotid, ]); $filterconditions = json_decode($setreference->filtercondition, true); $filterconditions = \core_question\question_reference_manager::convert_legacy_set_reference_filter_condition( $filterconditions, ); $params = \core_question\local\bank\filter_condition_manager::filter_invalid_values($filterconditions); } if (!empty($args['savedfiltercondition'])) { $filtercondition = json_decode($args['savedfiltercondition'], true); $params['filter'] = $filtercondition['filter']; } $extraparams = []; $extraparams['quizcmid'] = clean_param($args['quizcmid'], PARAM_INT); $extraparams['cmid'] = clean_param($args['bankcmid'], PARAM_INT); // Build required parameters. [$contexts, $thispageurl, $cm, $pagevars, $extraparams] = build_required_parameters_for_custom_view($params, $extraparams); // Additional param to differentiate with other question bank view. $extraparams['view'] = mod_quiz\question\bank\random_question_view::class; $course = get_course($cm->course); require_capability('mod/quiz:manage', $contexts->lowest()); // Custom View. $questionbank = new mod_quiz\question\bank\random_question_view($contexts, $thispageurl, $course, $cm, $pagevars, $extraparams); $renderer = $PAGE->get_renderer('mod_quiz', 'edit'); $questionbankoutput = $renderer->question_bank_contents($questionbank, $pagevars); $maxrand = 100; for ($i = 1; $i <= min(100, $maxrand); $i++) { $randomcount[] = ['value' => $i, 'name' => $i]; } // Parent category select. $usablecontexts = $contexts->having_cap('moodle/question:useall'); $categoriesarray = helper::question_category_options($usablecontexts); $catoptions = []; foreach ($categoriesarray as $group => $opts) { // Options for each category group. $categories = []; foreach ($opts as $context => $name) { $categories[] = ['value' => $context, 'name' => $name]; } $catoptions[] = ['label' => $group, 'options' => $categories]; } // Template data. $data = [ 'questionbank' => $questionbankoutput, 'randomoptions' => $randomcount, 'questioncategoryoptions' => $catoptions, ]; if (!empty($slotid)) { $data['slotid'] = $slotid; } $helpicon = new \help_icon('parentcategory', 'question'); $data['questioncategoryhelp'] = $helpicon->export_for_template($renderer); $result = $OUTPUT->render_from_template('mod_quiz/add_random_question_form', $data); return $result; } /** * Callback to fetch the activity event type lang string. * * @param string $eventtype The event type. * @return lang_string The event type lang string. */ function mod_quiz_core_calendar_get_event_action_string(string $eventtype): string { $modulename = get_string('modulename', 'quiz'); switch ($eventtype) { case QUIZ_EVENT_TYPE_OPEN: $identifier = 'quizeventopens'; break; case QUIZ_EVENT_TYPE_CLOSE: $identifier = 'quizeventcloses'; break; default: return get_string('requiresaction', 'calendar', $modulename); } return get_string($identifier, 'quiz', $modulename); } /** * Delete all question references for a quiz. * * @param int $quizid The id of quiz. */ function quiz_delete_references($quizid): void { global $DB; $cm = get_coursemodule_from_instance('quiz', $quizid); $context = context_module::instance($cm->id); $conditions = [ 'usingcontextid' => $context->id, 'component' => 'mod_quiz', 'questionarea' => 'slot', ]; $DB->delete_records('question_references', $conditions); $DB->delete_records('question_set_references', $conditions); } /** * Question data fragment to get the question html via ajax call. * * @param array $args * @return string */ function mod_quiz_output_fragment_question_data(array $args): string { // Return if there is no args. if (empty($args)) { return ''; } // Retrieve params from query string. [$params, $extraparams] = \core_question\local\bank\filter_condition_manager::extract_parameters_from_fragment_args($args); // Build required parameters. $cmid = clean_param($args['cmid'], PARAM_INT); $thispageurl = new \moodle_url('/mod/quiz/edit.php', ['cmid' => $cmid]); $thiscontext = \context_module::instance($cmid); $contexts = new \core_question\local\bank\question_edit_contexts($thiscontext); $defaultcategory = question_get_default_category($contexts->lowest()->id, true); $params['cat'] = implode(',', [$defaultcategory->id, $defaultcategory->contextid]); $course = get_course($params['courseid']); // The viewing bank mod id. [, $cm] = get_module_from_cmid(clean_param($args['cmid'], PARAM_INT)); $params['tabname'] = 'questions'; $extraparams['quizcmid'] = clean_param($args['quizcmid'], PARAM_INT); // Custom question bank View. $viewclass = clean_param($args['view'], PARAM_NOTAGS); $questionbank = new $viewclass($contexts, $thispageurl, $course, $cm, $params, $extraparams); // Question table. $questionbank->add_standard_search_conditions(); ob_start(); $questionbank->display_question_list(); return ob_get_clean(); } /** * Build required parameters for question bank custom view * * @param array $params the page parameters * @param array $extraparams additional parameters * @return array */ function build_required_parameters_for_custom_view(array $params, array $extraparams): array { // Retrieve questions per page. $viewclass = $extraparams['view'] ?? null; $defaultpagesize = $viewclass ? $viewclass::DEFAULT_PAGE_SIZE : DEFAULT_QUESTIONS_PER_PAGE; // Build the required params. [$thispageurl, $contexts, $cmid, $cm, , $pagevars] = question_build_edit_resources( 'editq', '/mod/quiz/edit.php', array_merge($params, $extraparams), $defaultpagesize); // Add cmid so we can retrieve later in extra params. $extraparams['cmid'] = $cmid; $extraparams['requirebankswitch'] = !empty(question_bank_helper::get_activity_types_with_shareable_questions()); return [$contexts, $thispageurl, $cm, $pagevars, $extraparams]; } /** * Implement the calculate_question_stats callback. * * This enables quiz statistics to be shown in statistics columns in the database. * * @param context $context return the statistics related to this context (which will be a quiz context). * @return all_calculated_for_qubaid_condition|null The statistics for this quiz, if available, else null. */ function mod_quiz_calculate_question_stats(context $context): ?all_calculated_for_qubaid_condition { global $CFG; require_once($CFG->dirroot . '/mod/quiz/report/statistics/report.php'); $cm = get_coursemodule_from_id('quiz', $context->instanceid); $report = new quiz_statistics_report(); return $report->calculate_questions_stats_for_question_bank($cm->instance, false, false); } /** * Return a list of all the user preferences used by mod_quiz. * * @uses core_user::is_current_user * * @return array[] */ function mod_quiz_user_preferences(): array { $preferences = []; $preferences['quiz_timerhidden'] = [ 'type' => PARAM_INT, 'null' => NULL_NOT_ALLOWED, 'default' => '0', 'permissioncallback' => [core_user::class, 'is_current_user'], ]; return $preferences; } PK9B\D version.phpnu[. /** * Quiz activity version information. * * @package mod_quiz * @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 = 'mod_quiz'; PK9B\ٛstartattempt.phpnu[. /** * This script deals with starting a new attempt at a quiz. * * Normally, it will end up redirecting to attempt.php - unless a password form is displayed. * * This code used to be at the top of attempt.php, if you are looking for CVS history. * * @package mod_quiz * @copyright 2009 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ use mod_quiz\quiz_attempt; use mod_quiz\quiz_settings; require_once(__DIR__ . '/../../config.php'); require_once($CFG->dirroot . '/mod/quiz/locallib.php'); // Get submitted parameters. $id = required_param('cmid', PARAM_INT); // Course module id $forcenew = optional_param('forcenew', false, PARAM_BOOL); // Used to force a new preview $page = optional_param('page', -1, PARAM_INT); // Page to jump to in the attempt. $quizobj = quiz_settings::create_for_cmid($id, $USER->id); // This script should only ever be posted to, so set page URL to the view page. $PAGE->set_url($quizobj->view_url()); // During quiz attempts, the browser back/forwards buttons should force a reload. $PAGE->set_cacheable(false); // Check login and sesskey. require_login($quizobj->get_course(), false, $quizobj->get_cm()); require_sesskey(); $PAGE->set_heading($quizobj->get_course()->fullname); // If no questions have been set up yet redirect to edit.php or display an error. if (!$quizobj->has_questions()) { if ($quizobj->has_capability('mod/quiz:manage')) { redirect($quizobj->edit_url()); } else { throw new \moodle_exception('cannotstartnoquestions', 'quiz', $quizobj->view_url()); } } // Create an object to manage all the other (non-roles) access rules. $timenow = time(); $accessmanager = $quizobj->get_access_manager($timenow); // Validate permissions for creating a new attempt and start a new preview attempt if required. list($currentattemptid, $attemptnumber, $lastattempt, $messages, $page) = quiz_validate_new_attempt($quizobj, $accessmanager, $forcenew, $page, true); // Check access. if (!$quizobj->is_preview_user() && $messages) { $output = $PAGE->get_renderer('mod_quiz'); throw new \moodle_exception('attempterror', 'quiz', $quizobj->view_url(), $output->access_messages($messages)); } if ($accessmanager->is_preflight_check_required($currentattemptid)) { // Need to do some checks before allowing the user to continue. $mform = $accessmanager->get_preflight_check_form( $quizobj->start_attempt_url($page), $currentattemptid); if ($mform->is_cancelled()) { $accessmanager->back_to_view_page($PAGE->get_renderer('mod_quiz')); } else if (!$mform->get_data()) { // Form not submitted successfully, re-display it and stop. $PAGE->set_url($quizobj->start_attempt_url($page)); $PAGE->set_title($quizobj->get_quiz_name()); $accessmanager->setup_attempt_page($PAGE); $output = $PAGE->get_renderer('mod_quiz'); if (empty($quizobj->get_quiz()->showblocks)) { $PAGE->blocks->show_only_fake_blocks(); } echo $output->start_attempt_page($quizobj, $mform); die(); } // Pre-flight check passed. $accessmanager->notify_preflight_check_passed($currentattemptid); } if (!$currentattemptid || $lastattempt->state == quiz_attempt::NOT_STARTED) { $attempt = quiz_prepare_and_start_new_attempt($quizobj, $attemptnumber, $lastattempt); } else { $attempt = $lastattempt; } if ($attempt->state === quiz_attempt::OVERDUE) { redirect($quizobj->summary_url($attempt->id)); } else { redirect($quizobj->attempt_url($attempt->id, $page)); } PK9B\4YY attempt.phpnu[. /** * This script displays a particular page of a quiz attempt that is in progress. * * @package mod_quiz * @copyright 1999 onwards Martin Dougiamas {@link http://moodle.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ use mod_quiz\output\navigation_panel_attempt; use mod_quiz\output\renderer; use mod_quiz\quiz_attempt; require_once(__DIR__ . '/../../config.php'); require_once($CFG->dirroot . '/mod/quiz/locallib.php'); // Look for old-style URLs, such as may be in the logs, and redirect them to startattemtp.php. if ($id = optional_param('id', 0, PARAM_INT)) { redirect($CFG->wwwroot . '/mod/quiz/startattempt.php?cmid=' . $id . '&sesskey=' . sesskey()); } else if ($qid = optional_param('q', 0, PARAM_INT)) { if (!$cm = get_coursemodule_from_instance('quiz', $qid)) { throw new \moodle_exception('invalidquizid', 'quiz'); } redirect(new moodle_url('/mod/quiz/startattempt.php', ['cmid' => $cm->id, 'sesskey' => sesskey()])); } // Get submitted parameters. $attemptid = required_param('attempt', PARAM_INT); $page = optional_param('page', 0, PARAM_INT); $cmid = optional_param('cmid', null, PARAM_INT); $attemptobj = quiz_create_attempt_handling_errors($attemptid, $cmid); $page = $attemptobj->force_page_number_into_range($page); $PAGE->set_url($attemptobj->attempt_url(null, $page)); // During quiz attempts, the browser back/forwards buttons should force a reload. $PAGE->set_cacheable(false); $PAGE->set_secondary_active_tab("modulepage"); // Check login. require_login($attemptobj->get_course(), false, $attemptobj->get_cm()); // Check that this attempt belongs to this user. if ($attemptobj->get_userid() != $USER->id) { if ($attemptobj->has_capability('mod/quiz:viewreports')) { redirect($attemptobj->review_url(null, $page)); } else { throw new moodle_exception('notyourattempt', 'quiz', $attemptobj->view_url()); } } // Check capabilities and block settings. if (!$attemptobj->is_preview_user()) { $attemptobj->require_capability('mod/quiz:attempt'); if (empty($attemptobj->get_quiz()->showblocks)) { $PAGE->blocks->show_only_fake_blocks(); } } else { navigation_node::override_active_url($attemptobj->start_attempt_url()); } // If the attempt is already closed, send them to the review page. if ($attemptobj->is_finished()) { redirect($attemptobj->review_url(null, $page)); } else if ($attemptobj->get_state() == quiz_attempt::OVERDUE) { redirect($attemptobj->summary_url()); } // Check the access rules. $accessmanager = $attemptobj->get_access_manager(time()); $accessmanager->setup_attempt_page($PAGE); /** @var renderer $output */ $output = $PAGE->get_renderer('mod_quiz'); $messages = $accessmanager->prevent_access(); if (!$attemptobj->is_preview_user() && $messages) { throw new \moodle_exception('attempterror', 'quiz', $attemptobj->view_url(), $output->access_messages($messages)); } if ($accessmanager->is_preflight_check_required($attemptobj->get_attemptid())) { redirect($attemptobj->start_attempt_url(null, $page)); } // Set up auto-save if required. $autosaveperiod = get_config('quiz', 'autosaveperiod'); if ($autosaveperiod) { $PAGE->requires->string_for_js('strftimedatetimeshortaccurate', 'langconfig'); $PAGE->requires->string_for_js('lastautosave', 'quiz'); $PAGE->requires->yui_module('moodle-mod_quiz-autosave', 'M.mod_quiz.autosave.init', [$autosaveperiod]); } // Log this page view. $attemptobj->fire_attempt_viewed_event(); // Get the list of questions needed by this page. $slots = $attemptobj->get_slots($page); // Check. if (empty($slots)) { throw new moodle_exception('noquestionsfound', 'quiz', $attemptobj->view_url()); } // Update attempt page, redirecting the user if $page is not valid. if (!$attemptobj->set_currentpage($page)) { redirect($attemptobj->start_attempt_url(null, $attemptobj->get_currentpage())); } if ($attemptobj->is_own_preview()) { $attemptobj->update_questions_to_new_version_if_changed(); } // Initialise the JavaScript. $headtags = $attemptobj->get_html_head_contributions($page); $PAGE->requires->js_init_call('M.mod_quiz.init_attempt_form', null, false, quiz_get_js_module()); \core\session\manager::keepalive(); // Try to prevent sessions expiring during quiz attempts. // Arrange for the navigation to be displayed in the first region on the page. $navbc = $attemptobj->get_navigation_panel($output, navigation_panel_attempt::class, $page); $regions = $PAGE->blocks->get_regions(); $PAGE->blocks->add_fake_block($navbc, reset($regions)); $headtags = $attemptobj->get_html_head_contributions($page); $PAGE->set_title($attemptobj->attempt_page_title($page)); $PAGE->add_body_class('limitedwidth'); $PAGE->set_heading($attemptobj->get_course()->fullname); if ($PAGE->pagelayout === 'secure') { // Show the activity header (but only the name) in the secure layout on quiz pages. $PAGE->activityheader->set_attrs([ 'description' => '', ]); } else { $PAGE->activityheader->disable(); } if ($attemptobj->is_last_page($page)) { $nextpage = -1; } else { $nextpage = $page + 1; } echo $output->attempt_page($attemptobj, $page, $accessmanager, $messages, $slots, $id, $nextpage); PK9B\Moverrideedit.phpnu[. /** * This page handles editing and creation of quiz overrides * * @package mod_quiz * @copyright 2010 Matt Petro * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ use mod_quiz\form\edit_override_form; use mod_quiz\quiz_settings; require_once(__DIR__ . '/../../config.php'); require_once($CFG->dirroot.'/mod/quiz/lib.php'); require_once($CFG->dirroot.'/mod/quiz/locallib.php'); $cmid = optional_param('cmid', 0, PARAM_INT); $overrideid = optional_param('id', 0, PARAM_INT); $action = optional_param('action', null, PARAM_ALPHA); $reset = optional_param('reset', false, PARAM_BOOL); $override = null; if ($overrideid) { $override = $DB->get_record('quiz_overrides', ['id' => $overrideid], '*', MUST_EXIST); $quizobj = quiz_settings::create($override->quiz); } else { $quizobj = quiz_settings::create_for_cmid($cmid); } $quiz = $quizobj->get_quiz(); $cm = $quizobj->get_cm(); $course = $quizobj->get_course(); $context = $quizobj->get_context(); $manager = $quizobj->get_override_manager(); $url = new moodle_url('/mod/quiz/overrideedit.php'); if ($action) { $url->param('action', $action); } if ($overrideid) { $url->param('id', $overrideid); } else { $url->param('cmid', $cmid); } $PAGE->set_url($url); // Activate the secondary nav tab. $PAGE->set_secondary_active_tab("mod_quiz_useroverrides"); require_login($course, false, $cm); // Add or edit an override. $manager->require_manage_capability(); if ($overrideid) { // Editing an override. $data = clone $override; if (!$manager->can_view_override($override, $course, $cm)) { throw new \moodle_exception('invalidoverrideid', 'quiz'); } } else { // Creating a new override. $data = new stdClass(); } // Merge quiz defaults with data. $keys = ['timeopen', 'timeclose', 'timelimit', 'attempts', 'password']; foreach ($keys as $key) { if (!isset($data->{$key}) || $reset) { $data->{$key} = $quiz->{$key}; } } // If we are duplicating an override, then clear the user/group and override id // since they will change. if ($action === 'duplicate') { $override->id = null; $override->userid = null; $override->groupid = null; } // True if group-based override. $groupmode = !empty($data->groupid) || ($action === 'addgroup' && empty($overrideid)); $overridelisturl = new moodle_url('/mod/quiz/overrides.php', ['cmid' => $cm->id]); if (!$groupmode) { $overridelisturl->param('mode', 'user'); } // Setup the form. $mform = new edit_override_form($url, $cm, $quiz, $context, $groupmode, $override); $mform->set_data($data); if ($mform->is_cancelled()) { redirect($overridelisturl); } else if (optional_param('resetbutton', 0, PARAM_ALPHA)) { $url->param('reset', true); redirect($url); } else if ($fromform = $mform->get_data()) { // Only include id when editing (i.e. action is empty). if (empty($action) && !empty($overrideid)) { $fromform->id = $overrideid; } // Process the data. $id = $manager->save_override((array) $fromform); if (!empty($fromform->submitbutton)) { redirect($overridelisturl); } // The user pressed the 'again' button, so redirect back to this page. $url->remove_params('cmid'); $url->param('action', 'duplicate'); $url->param('id', $id); redirect($url); } // Print the form. $pagetitle = get_string('editoverride', 'quiz'); $PAGE->navbar->add($pagetitle); $PAGE->set_pagelayout('admin'); $PAGE->add_body_class('limitedwidth'); $PAGE->set_title($pagetitle); $PAGE->set_heading($course->fullname); $PAGE->activityheader->set_attrs([ "title" => format_string($quiz->name, true, ['context' => $context]), "description" => "", "hidecompletion" => true ]); echo $OUTPUT->header(); $mform->display(); echo $OUTPUT->footer(); PK9B\(W8BBclasses/external.phpnu[. /** * Quiz external API * * @package mod_quiz * @category external * @copyright 2016 Juan Leyva * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @since Moodle 3.1 */ use core_course\external\helper_for_get_mods_by_courses; use core_external\external_api; 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_single_structure; use core_external\external_value; use core_external\external_warnings; use core_external\util; use mod_quiz\access_manager; use mod_quiz\quiz_attempt; use mod_quiz\quiz_settings; defined('MOODLE_INTERNAL') || die; require_once($CFG->dirroot . '/mod/quiz/locallib.php'); /** * Quiz external functions * * @package mod_quiz * @category external * @copyright 2016 Juan Leyva * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @since Moodle 3.1 */ class mod_quiz_external extends external_api { /** * Describes the parameters for get_quizzes_by_courses. * * @return external_function_parameters * @since Moodle 3.1 */ public static function get_quizzes_by_courses_parameters() { return new external_function_parameters ( [ 'courseids' => new external_multiple_structure( new external_value(PARAM_INT, 'course id'), 'Array of course ids', VALUE_DEFAULT, [] ), ] ); } /** * Returns a list of quizzes in a provided list of courses, * if no list is provided all quizzes that the user can view will be returned. * * @param array $courseids Array of course ids * @return array of quizzes details * @since Moodle 3.1 */ public static function get_quizzes_by_courses($courseids = []) { global $USER; $warnings = []; $returnedquizzes = []; $params = [ 'courseids' => $courseids, ]; $params = self::validate_parameters(self::get_quizzes_by_courses_parameters(), $params); $mycourses = []; if (empty($params['courseids'])) { $mycourses = enrol_get_my_courses(); $params['courseids'] = array_keys($mycourses); } // Ensure there are courseids to loop through. if (!empty($params['courseids'])) { list($courses, $warnings) = util::validate_courses($params['courseids'], $mycourses); // Get the quizzes in this course, this function checks users visibility permissions. // We can avoid then additional validate_context calls. $quizzes = get_all_instances_in_courses("quiz", $courses); foreach ($quizzes as $quiz) { $context = context_module::instance($quiz->coursemodule); // Update quiz with override information. $quiz = quiz_update_effective_access($quiz, $USER->id); // Entry to return. $quizdetails = helper_for_get_mods_by_courses::standard_coursemodule_element_values( $quiz, 'mod_quiz', 'mod/quiz:view', 'mod/quiz:view'); if (has_capability('mod/quiz:view', $context)) { $quizdetails['introfiles'] = util::get_area_files($context->id, 'mod_quiz', 'intro', false, false); $viewablefields = ['timeopen', 'timeclose', 'attempts', 'timelimit', 'grademethod', 'decimalpoints', 'questiondecimalpoints', 'sumgrades', 'grade', 'preferredbehaviour']; // Sometimes this function returns just empty. $hasfeedback = quiz_has_feedback($quiz); $quizdetails['hasfeedback'] = (!empty($hasfeedback)) ? 1 : 0; $timenow = time(); $quizobj = quiz_settings::create($quiz->id, $USER->id); $accessmanager = new access_manager($quizobj, $timenow, has_capability('mod/quiz:ignoretimelimits', $context, null, false)); // Fields the user could see if have access to the quiz. if (!$accessmanager->prevent_access()) { $quizdetails['hasquestions'] = (int) $quizobj->has_questions(); $quizdetails['autosaveperiod'] = get_config('quiz', 'autosaveperiod'); $additionalfields = ['attemptonlast', 'reviewattempt', 'reviewcorrectness', 'reviewmaxmarks', 'reviewmarks', 'reviewspecificfeedback', 'reviewgeneralfeedback', 'reviewrightanswer', 'reviewoverallfeedback', 'questionsperpage', 'navmethod', 'browsersecurity', 'delay1', 'delay2', 'showuserpicture', 'showblocks', 'completionattemptsexhausted', 'overduehandling', 'graceperiod', 'canredoquestions', 'allowofflineattempts']; $viewablefields = array_merge($viewablefields, $additionalfields); // Any course module fields that previously existed in quiz. $quizdetails['completionpass'] = $quizobj->get_cm()->completionpassgrade; } // Fields only for managers. if (has_capability('moodle/course:manageactivities', $context)) { $additionalfields = [ 'shuffleanswers', 'timecreated', 'timemodified', 'password', 'subnet', 'precreateattempts', ]; $viewablefields = array_merge($viewablefields, $additionalfields); } foreach ($viewablefields as $field) { $quizdetails[$field] = $quiz->{$field}; } } $returnedquizzes[] = $quizdetails; } } $result = []; $result['quizzes'] = $returnedquizzes; $result['warnings'] = $warnings; return $result; } /** * Describes the get_quizzes_by_courses return value. * * @return external_single_structure * @since Moodle 3.1 */ public static function get_quizzes_by_courses_returns() { return new external_single_structure( [ 'quizzes' => new external_multiple_structure( new external_single_structure(array_merge( helper_for_get_mods_by_courses::standard_coursemodule_elements_returns(true), [ 'timeopen' => new external_value(PARAM_INT, 'The time when this quiz opens. (0 = no restriction.)', VALUE_OPTIONAL), 'timeclose' => new external_value(PARAM_INT, 'The time when this quiz closes. (0 = no restriction.)', VALUE_OPTIONAL), 'timelimit' => new external_value(PARAM_INT, 'The time limit for quiz attempts, in seconds.', VALUE_OPTIONAL), 'overduehandling' => new external_value(PARAM_ALPHA, 'The method used to handle overdue attempts. \'autosubmit\', \'graceperiod\' or \'autoabandon\'.', VALUE_OPTIONAL), 'graceperiod' => new external_value(PARAM_INT, 'The amount of time (in seconds) after the time limit runs out during which attempts can still be submitted, if overduehandling is set to allow it.', VALUE_OPTIONAL), 'preferredbehaviour' => new external_value(PARAM_ALPHANUMEXT, 'The behaviour to ask questions to use.', VALUE_OPTIONAL), 'canredoquestions' => new external_value(PARAM_INT, 'Allows students to redo any completed question within a quiz attempt.', VALUE_OPTIONAL), 'attempts' => new external_value(PARAM_INT, 'The maximum number of attempts a student is allowed.', VALUE_OPTIONAL), 'attemptonlast' => new external_value(PARAM_INT, 'Whether subsequent attempts start from the answer to the previous attempt (1) or start blank (0).', VALUE_OPTIONAL), 'grademethod' => new external_value(PARAM_INT, 'One of the values QUIZ_GRADEHIGHEST, QUIZ_GRADEAVERAGE, QUIZ_ATTEMPTFIRST or QUIZ_ATTEMPTLAST.', VALUE_OPTIONAL), 'decimalpoints' => new external_value(PARAM_INT, 'Number of decimal points to use when displaying grades.', VALUE_OPTIONAL), 'questiondecimalpoints' => new external_value(PARAM_INT, 'Number of decimal points to use when displaying question grades. (-1 means use decimalpoints.)', VALUE_OPTIONAL), 'reviewattempt' => new external_value(PARAM_INT, 'Whether users are allowed to review their quiz attempts at various times. This is a bit field, decoded by the \mod_quiz\question\display_options class. It is formed by ORing together the constants defined there.', VALUE_OPTIONAL), 'reviewcorrectness' => new external_value(PARAM_INT, 'Whether users are allowed to review their quiz attempts at various times.A bit field, like reviewattempt.', VALUE_OPTIONAL), 'reviewmaxmarks' => new external_value(PARAM_INT, 'Whether users are allowed to review their quiz attempts at various times. A bit field, like reviewattempt.', VALUE_OPTIONAL), 'reviewmarks' => new external_value(PARAM_INT, 'Whether users are allowed to review their quiz attempts at various times. A bit field, like reviewattempt.', VALUE_OPTIONAL), 'reviewspecificfeedback' => new external_value(PARAM_INT, 'Whether users are allowed to review their quiz attempts at various times. A bit field, like reviewattempt.', VALUE_OPTIONAL), 'reviewgeneralfeedback' => new external_value(PARAM_INT, 'Whether users are allowed to review their quiz attempts at various times. A bit field, like reviewattempt.', VALUE_OPTIONAL), 'reviewrightanswer' => new external_value(PARAM_INT, 'Whether users are allowed to review their quiz attempts at various times. A bit field, like reviewattempt.', VALUE_OPTIONAL), 'reviewoverallfeedback' => new external_value(PARAM_INT, 'Whether users are allowed to review their quiz attempts at various times. A bit field, like reviewattempt.', VALUE_OPTIONAL), 'questionsperpage' => new external_value(PARAM_INT, 'How often to insert a page break when editing the quiz, or when shuffling the question order.', VALUE_OPTIONAL), 'navmethod' => new external_value(PARAM_ALPHA, 'Any constraints on how the user is allowed to navigate around the quiz. Currently recognised values are \'free\' and \'seq\'.', VALUE_OPTIONAL), 'shuffleanswers' => new external_value(PARAM_INT, 'Whether the parts of the question should be shuffled, in those question types that support it.', VALUE_OPTIONAL), 'sumgrades' => new external_value(PARAM_FLOAT, 'The total of all the question instance maxmarks.', VALUE_OPTIONAL), 'grade' => new external_value(PARAM_FLOAT, 'The total that the quiz overall grade is scaled to be out of.', VALUE_OPTIONAL), 'timecreated' => new external_value(PARAM_INT, 'The time when the quiz was added to the course.', VALUE_OPTIONAL), 'timemodified' => new external_value(PARAM_INT, 'Last modified time.', VALUE_OPTIONAL), 'password' => new external_value(PARAM_RAW, 'A password that the student must enter before starting or continuing a quiz attempt.', VALUE_OPTIONAL), 'subnet' => new external_value(PARAM_RAW, 'Used to restrict the IP addresses from which this quiz can be attempted. The format is as requried by the address_in_subnet function.', VALUE_OPTIONAL), 'browsersecurity' => new external_value(PARAM_ALPHANUMEXT, 'Restriciton on the browser the student must use. E.g. \'securewindow\'.', VALUE_OPTIONAL), 'delay1' => new external_value(PARAM_INT, 'Delay that must be left between the first and second attempt, in seconds.', VALUE_OPTIONAL), 'delay2' => new external_value(PARAM_INT, 'Delay that must be left between the second and subsequent attempt, in seconds.', VALUE_OPTIONAL), 'showuserpicture' => new external_value(PARAM_INT, 'Option to show the user\'s picture during the attempt and on the review page.', VALUE_OPTIONAL), 'showblocks' => new external_value(PARAM_INT, 'Whether blocks should be shown on the attempt.php and review.php pages.', VALUE_OPTIONAL), 'completionattemptsexhausted' => new external_value(PARAM_INT, 'Mark quiz complete when the student has exhausted the maximum number of attempts', VALUE_OPTIONAL), 'completionpass' => new external_value(PARAM_INT, 'Whether to require passing grade', VALUE_OPTIONAL), 'allowofflineattempts' => new external_value(PARAM_INT, 'Whether to allow the quiz to be attempted offline in the mobile app', VALUE_OPTIONAL), 'autosaveperiod' => new external_value(PARAM_INT, 'Auto-save delay', VALUE_OPTIONAL), 'hasfeedback' => new external_value(PARAM_INT, 'Whether the quiz has any non-blank feedback text', VALUE_OPTIONAL), 'hasquestions' => new external_value(PARAM_INT, 'Whether the quiz has questions', VALUE_OPTIONAL), 'precreateattempts' => new external_value(PARAM_INT, 'Whether attempt pre-creation is enabled', VALUE_OPTIONAL), ] )) ), 'warnings' => new external_warnings(), ] ); } /** * Utility function for validating a quiz. * * @param int $quizid quiz instance id * @return array array containing the quiz, course, context and course module objects * @since Moodle 3.1 */ protected static function validate_quiz($quizid) { global $DB; // Request and permission validation. $quiz = $DB->get_record('quiz', ['id' => $quizid], '*', MUST_EXIST); list($course, $cm) = get_course_and_cm_from_instance($quiz, 'quiz'); $context = context_module::instance($cm->id); self::validate_context($context); return [$quiz, $course, $cm, $context]; } /** * Describes the parameters for view_quiz. * * @return external_function_parameters * @since Moodle 3.1 */ public static function view_quiz_parameters() { return new external_function_parameters ( [ 'quizid' => new external_value(PARAM_INT, 'quiz instance id'), ] ); } /** * Trigger the course module viewed event and update the module completion status. * * @param int $quizid quiz instance id * @return array of warnings and status result * @since Moodle 3.1 */ public static function view_quiz($quizid) { global $DB; $params = self::validate_parameters(self::view_quiz_parameters(), ['quizid' => $quizid]); $warnings = []; list($quiz, $course, $cm, $context) = self::validate_quiz($params['quizid']); // Trigger course_module_viewed event and completion. quiz_view($quiz, $course, $cm, $context); $result = []; $result['status'] = true; $result['warnings'] = $warnings; return $result; } /** * Describes the view_quiz return value. * * @return external_single_structure * @since Moodle 3.1 */ public static function view_quiz_returns() { return new external_single_structure( [ 'status' => new external_value(PARAM_BOOL, 'status: true if success'), 'warnings' => new external_warnings(), ] ); } /** * Describes the parameters for get_user_attempts. * * @return external_function_parameters * @since Moodle 3.1 * @deprecated Since Moodle 5.0 MDL-68806. * @todo Final deprecation in Moodle 6.0 (MDL-80956) */ #[\core\attribute\deprecated( 'mod_quiz_external::get_user_quiz_attempts_parameters', since: '5.0', reason: 'The old API for fetching attempts doesn\'t return true states for NOT_STARTED and SUBMITTED attempts', mdl: 'MDL-68806' )] public static function get_user_attempts_parameters() { return new external_function_parameters ( [ 'quizid' => new external_value(PARAM_INT, 'quiz instance id'), 'userid' => new external_value(PARAM_INT, 'user id, empty for current user', VALUE_DEFAULT, 0), 'status' => new external_value(PARAM_ALPHA, 'quiz status: all, finished or unfinished', VALUE_DEFAULT, 'finished'), 'includepreviews' => new external_value(PARAM_BOOL, 'whether to include previews or not', VALUE_DEFAULT, false), ] ); } /** * Return a list of attempts for the given quiz and user. * * For backwards compatibility, SUBMITTED attempts will be treated as FINISHED with marks hidden, and NOT_STARTED will not * be returned. To return all real states, call get_user_quiz_attempts instead. * * @param int $quizid quiz instance id * @param int $userid user id * @param string $status quiz status: all, finished or unfinished * @param bool $includepreviews whether to include previews or not * @return array of warnings and the list of attempts * @since Moodle 3.1 * @deprecated Since Moodle 5.0 MDL-68806. * @todo Final deprecation in Moodle 6.0 (MDL-80956) */ #[\core\attribute\deprecated( 'mod_quiz_external::get_user_quiz_attempts', since: '5.0', reason: 'The old API for fetching attempts doesn\'t return true states for NOT_STARTED and SUBMITTED attempts', mdl: 'MDL-68806' )] public static function get_user_attempts($quizid, $userid = 0, $status = 'finished', $includepreviews = false) { global $USER; \core\deprecation::emit_deprecation(__METHOD__); $warnings = []; $params = [ 'quizid' => $quizid, 'userid' => $userid, 'status' => $status, 'includepreviews' => $includepreviews, ]; $params = self::validate_parameters(self::get_user_attempts_parameters(), $params); list($quiz, $course, $cm, $context) = self::validate_quiz($params['quizid']); if (!in_array($params['status'], ['all', 'finished', 'unfinished'])) { throw new invalid_parameter_exception('Invalid status value'); } // Default value for userid. if (empty($params['userid'])) { $params['userid'] = $USER->id; } $user = core_user::get_user($params['userid'], '*', MUST_EXIST); core_user::require_active_user($user); // Extra checks so only users with permissions can view other users attempts. if ($USER->id != $user->id) { require_capability('mod/quiz:viewreports', $context); } // Update quiz with override information. $quiz = quiz_update_effective_access($quiz, $params['userid']); $attempts = quiz_get_user_attempts($quiz->id, $user->id, $params['status'], $params['includepreviews']); $quizobj = new quiz_settings($quiz, $cm, $course); $gradeitemmarks = $quizobj->get_grade_calculator()->compute_grade_item_totals_for_attempts( array_column($attempts, 'uniqueid')); $attemptresponse = []; foreach ($attempts as $attempt) { if ($attempt->state == quiz_attempt::NOT_STARTED) { continue; // For backwards compatibility, do not return Not Started attempts. } $reviewoptions = quiz_get_review_options($quiz, $attempt, $context); if ( $attempt->state == quiz_attempt::SUBMITTED || ( !has_capability('mod/quiz:viewreports', $context) && ( $reviewoptions->marks < question_display_options::MARK_AND_MAX || $attempt->state != quiz_attempt::FINISHED ) ) ) { // Blank the mark if the teacher does not allow it. $attempt->sumgrades = null; } else if (isset($gradeitemmarks[$attempt->uniqueid])) { $attempt->gradeitemmarks = []; foreach ($gradeitemmarks[$attempt->uniqueid] as $gradeitem) { $attempt->gradeitemmarks[] = [ 'name' => \core_external\util::format_string($gradeitem->name, $context), 'grade' => $gradeitem->grade, 'maxgrade' => $gradeitem->maxgrade, ]; } } if ($attempt->state == quiz_attempt::SUBMITTED) { $attempt->state = quiz_attempt::FINISHED; // For backwards-compatibility. } $attemptresponse[] = $attempt; } $result = []; $result['attempts'] = $attemptresponse; $result['warnings'] = $warnings; return $result; } /** * Describes a single attempt structure. * * @return external_single_structure the attempt structure */ private static function attempt_structure() { return new external_single_structure( [ 'id' => new external_value(PARAM_INT, 'Attempt id.', VALUE_OPTIONAL), 'quiz' => new external_value(PARAM_INT, 'Foreign key reference to the quiz that was attempted.', VALUE_OPTIONAL), 'userid' => new external_value(PARAM_INT, 'Foreign key reference to the user whose attempt this is.', VALUE_OPTIONAL), 'attempt' => new external_value(PARAM_INT, 'Sequentially numbers this students attempts at this quiz.', VALUE_OPTIONAL), 'uniqueid' => new external_value(PARAM_INT, 'Foreign key reference to the question_usage that holds the details of the the question_attempts that make up this quiz attempt.', VALUE_OPTIONAL), 'layout' => new external_value(PARAM_RAW, 'Attempt layout.', VALUE_OPTIONAL), 'currentpage' => new external_value(PARAM_INT, 'Attempt current page.', VALUE_OPTIONAL), 'preview' => new external_value(PARAM_INT, 'Whether is a preview attempt or not.', VALUE_OPTIONAL), 'state' => new external_value(PARAM_ALPHA, 'The current state of the attempts. \'inprogress\', \'overdue\', \'finished\' or \'abandoned\'.', VALUE_OPTIONAL), 'timestart' => new external_value(PARAM_INT, 'Time when the attempt was started.', VALUE_OPTIONAL), 'timefinish' => new external_value(PARAM_INT, 'Time when the attempt was submitted. 0 if the attempt has not been submitted yet.', VALUE_OPTIONAL), 'timemodified' => new external_value(PARAM_INT, 'Last modified time.', VALUE_OPTIONAL), 'timemodifiedoffline' => new external_value(PARAM_INT, 'Last modified time via webservices.', VALUE_OPTIONAL), 'timecheckstate' => new external_value(PARAM_INT, 'Next time quiz cron should check attempt for state changes. NULL means never check.', VALUE_OPTIONAL), 'sumgrades' => new external_value(PARAM_FLOAT, 'Total marks for this attempt.', VALUE_OPTIONAL), 'gradeitemmarks' => new external_multiple_structure( new external_single_structure([ 'name' => new external_value(PARAM_RAW, 'The name of this grade item.'), 'grade' => new external_value(PARAM_FLOAT, 'The grade this attempt earned for this item.'), 'maxgrade' => new external_value(PARAM_FLOAT, 'The total this grade is out of.'), ], 'The grade for each grade item.'), 'If the quiz has additional grades set up, the mark for each grade for this attempt.', VALUE_OPTIONAL), 'gradednotificationsenttime' => new external_value(PARAM_INT, 'Time when the student was notified that manual grading of their attempt was complete.', VALUE_OPTIONAL), ] ); } /** * Describes the get_user_attempts return value. * * @return external_single_structure * @since Moodle 3.1 * @deprecated Since Moodle 5.0 MDL-68806. * @todo Final deprecation in Moodle 6.0 (MDL-80956) */ #[\core\attribute\deprecated( 'mod_quiz_external::get_user_quiz_attempts_returns', since: '5.0', reason: 'The old API for fetching attempts doesn\'t return true states for NOT_STARTED and SUBMITTED attempts', mdl: 'MDL-68806' )] public static function get_user_attempts_returns() { $attemptstructure = self::attempt_structure(); $attemptstructure->keys['state']->desc .= " For backwards compatibility, attempts in 'submitted' state will return " . "'finished' and attempts in 'notstarted' state will return 'inprogress'. To get attempts with all real states, call " . "get_user_quiz_attempts() instead."; return new external_single_structure( [ 'attempts' => new external_multiple_structure($attemptstructure), 'warnings' => new external_warnings(), ] ); } /** * Mark get_user_attempts as deprecated. * * @return bool */ public static function get_user_attempts_is_deprecated(): bool { return true; } /** * Describes the parameters for get_user_quiz_attempts. * * @return external_function_parameters * @since Moodle 4.5 */ public static function get_user_quiz_attempts_parameters(): external_function_parameters { return new external_function_parameters ( [ 'quizid' => new external_value(PARAM_INT, 'quiz instance id'), 'userid' => new external_value(PARAM_INT, 'user id, empty for current user', VALUE_DEFAULT, 0), 'status' => new external_value(PARAM_ALPHA, 'quiz status: all, finished or unfinished', VALUE_DEFAULT, 'finished'), 'includepreviews' => new external_value(PARAM_BOOL, 'whether to include previews or not', VALUE_DEFAULT, false), ], ); } /** * Return a list of attempts for the given quiz and user. * * @param int $quizid quiz instance id * @param int $userid user id * @param string $status quiz status: all, finished or unfinished * @param bool $includepreviews whether to include previews or not * @return array of warnings and the list of attempts * @since Moodle 4.5 */ public static function get_user_quiz_attempts( int $quizid, int $userid = 0, string $status = 'finished', bool $includepreviews = false ): array { global $USER; $warnings = []; $params = [ 'quizid' => $quizid, 'userid' => $userid, 'status' => $status, 'includepreviews' => $includepreviews, ]; $params = self::validate_parameters(self::get_user_quiz_attempts_parameters(), $params); [$quiz, $course, $cm, $context] = self::validate_quiz($params['quizid']); if (!in_array($params['status'], ['all', 'finished', 'unfinished'])) { throw new invalid_parameter_exception('Invalid status value'); } // Default value for userid. if (empty($params['userid'])) { $params['userid'] = $USER->id; } $user = core_user::get_user($params['userid'], '*', MUST_EXIST); core_user::require_active_user($user); // Extra checks so only users with permissions can view other users attempts. if ($USER->id != $user->id) { require_capability('mod/quiz:viewreports', $context); } // Update quiz with override information. $quiz = quiz_update_effective_access($quiz, $params['userid']); $attempts = quiz_get_user_attempts($quiz->id, $user->id, $params['status'], $params['includepreviews']); $quizobj = new quiz_settings($quiz, $cm, $course); $gradeitemmarks = $quizobj->get_grade_calculator()->compute_grade_item_totals_for_attempts( array_column($attempts, 'uniqueid')); $attemptresponse = []; foreach ($attempts as $attempt) { $reviewoptions = quiz_get_review_options($quiz, $attempt, $context); if (!has_capability('mod/quiz:viewreports', $context) && ($reviewoptions->marks < question_display_options::MARK_AND_MAX || $attempt->state != quiz_attempt::FINISHED)) { // Blank the mark if the teacher does not allow it. $attempt->sumgrades = null; } else if (isset($gradeitemmarks[$attempt->uniqueid])) { $attempt->gradeitemmarks = []; foreach ($gradeitemmarks[$attempt->uniqueid] as $gradeitem) { $attempt->gradeitemmarks[] = [ 'name' => \core_external\util::format_string($gradeitem->name, $context), 'grade' => $gradeitem->grade, 'maxgrade' => $gradeitem->maxgrade, ]; } } $attemptresponse[] = $attempt; } $result = []; $result['attempts'] = $attemptresponse; $result['warnings'] = $warnings; return $result; } /** * Describes the get_user_attempts return value. * * @return external_single_structure * @since Moodle 4.5 */ public static function get_user_quiz_attempts_returns(): external_single_structure { return new external_single_structure( [ 'attempts' => new external_multiple_structure(self::attempt_structure()), 'warnings' => new external_warnings(), ], ); } /** * Describes the parameters for get_user_best_grade. * * @return external_function_parameters * @since Moodle 3.1 */ public static function get_user_best_grade_parameters() { return new external_function_parameters ( [ 'quizid' => new external_value(PARAM_INT, 'quiz instance id'), 'userid' => new external_value(PARAM_INT, 'user id', VALUE_DEFAULT, 0), ] ); } /** * Get the best current grade for the given user on a quiz. * * @param int $quizid quiz instance id * @param int $userid user id * @return array of warnings and the grade information * @since Moodle 3.1 */ public static function get_user_best_grade($quizid, $userid = 0) { global $DB, $USER, $CFG; require_once($CFG->libdir . '/gradelib.php'); $warnings = []; $params = [ 'quizid' => $quizid, 'userid' => $userid, ]; $params = self::validate_parameters(self::get_user_best_grade_parameters(), $params); list($quiz, $course, $cm, $context) = self::validate_quiz($params['quizid']); // Default value for userid. if (empty($params['userid'])) { $params['userid'] = $USER->id; } $user = core_user::get_user($params['userid'], '*', MUST_EXIST); core_user::require_active_user($user); // Extra checks so only users with permissions can view other users attempts. if ($USER->id != $user->id) { require_capability('mod/quiz:viewreports', $context); } $result = []; // This code was mostly copied from mod/quiz/view.php. We need to make the web service logic consistent. // Get this user's attempts. $attempts = quiz_get_user_attempts($quiz->id, $user->id, 'all'); $canviewgrade = false; if ($attempts) { if ($USER->id != $user->id) { // No need to check the permission here. We did it at by require_capability('mod/quiz:viewreports', $context). $canviewgrade = true; } else { // Work out which columns we need, taking account what data is available in each attempt. [$notused, $alloptions] = quiz_get_combined_reviewoptions($quiz, $attempts); $canviewgrade = $alloptions->marks >= question_display_options::MARK_AND_MAX; } } $grade = $canviewgrade ? quiz_get_best_grade($quiz, $user->id) : null; if ($grade === null) { $result['hasgrade'] = false; } else { $result['hasgrade'] = true; $result['grade'] = $grade; } // Inform user of the grade to pass if non-zero. $gradinginfo = grade_get_grades($course->id, 'mod', 'quiz', $quiz->id, $user->id); if (!empty($gradinginfo->items)) { $item = $gradinginfo->items[0]; if ($item && grade_floats_different($item->gradepass, 0)) { $result['gradetopass'] = $item->gradepass; } } $result['warnings'] = $warnings; return $result; } /** * Describes the get_user_best_grade return value. * * @return external_single_structure * @since Moodle 3.1 */ public static function get_user_best_grade_returns() { return new external_single_structure( [ 'hasgrade' => new external_value(PARAM_BOOL, 'Whether the user has a grade on the given quiz.'), 'grade' => new external_value(PARAM_FLOAT, 'The grade (only if the user has a grade).', VALUE_OPTIONAL), 'gradetopass' => new external_value(PARAM_FLOAT, 'The grade to pass the quiz (only if set).', VALUE_OPTIONAL), 'warnings' => new external_warnings(), ] ); } /** * Describes the parameters for get_combined_review_options. * * @return external_function_parameters * @since Moodle 3.1 */ public static function get_combined_review_options_parameters() { return new external_function_parameters ( [ 'quizid' => new external_value(PARAM_INT, 'quiz instance id'), 'userid' => new external_value(PARAM_INT, 'user id (empty for current user)', VALUE_DEFAULT, 0), ] ); } /** * Combines the review options from a number of different quiz attempts. * * @param int $quizid quiz instance id * @param int $userid user id (empty for current user) * @return array of warnings and the review options * @since Moodle 3.1 */ public static function get_combined_review_options($quizid, $userid = 0) { global $DB, $USER; $warnings = []; $params = [ 'quizid' => $quizid, 'userid' => $userid, ]; $params = self::validate_parameters(self::get_combined_review_options_parameters(), $params); list($quiz, $course, $cm, $context) = self::validate_quiz($params['quizid']); // Default value for userid. if (empty($params['userid'])) { $params['userid'] = $USER->id; } $user = core_user::get_user($params['userid'], '*', MUST_EXIST); core_user::require_active_user($user); // Extra checks so only users with permissions can view other users attempts. if ($USER->id != $user->id) { require_capability('mod/quiz:viewreports', $context); } // Update quiz with override information. $quiz = quiz_update_effective_access($quiz, $params['userid']); $attempts = quiz_get_user_attempts($quiz->id, $user->id, 'all', true); $result = []; $result['someoptions'] = []; $result['alloptions'] = []; list($someoptions, $alloptions) = quiz_get_combined_reviewoptions($quiz, $attempts); foreach (['someoptions', 'alloptions'] as $typeofoption) { foreach ($$typeofoption as $key => $value) { $result[$typeofoption][] = [ "name" => $key, "value" => (!empty($value)) ? $value : 0 ]; } } $result['warnings'] = $warnings; return $result; } /** * Describes the get_combined_review_options return value. * * @return external_single_structure * @since Moodle 3.1 */ public static function get_combined_review_options_returns() { return new external_single_structure( [ 'someoptions' => new external_multiple_structure( new external_single_structure( [ 'name' => new external_value(PARAM_ALPHANUMEXT, 'option name'), 'value' => new external_value(PARAM_INT, 'option value'), ] ) ), 'alloptions' => new external_multiple_structure( new external_single_structure( [ 'name' => new external_value(PARAM_ALPHANUMEXT, 'option name'), 'value' => new external_value(PARAM_INT, 'option value'), ] ) ), 'warnings' => new external_warnings(), ] ); } /** * Describes the parameters for start_attempt. * * @return external_function_parameters * @since Moodle 3.1 */ public static function start_attempt_parameters() { return new external_function_parameters ( [ 'quizid' => new external_value(PARAM_INT, 'quiz instance id'), 'preflightdata' => new external_multiple_structure( new external_single_structure( [ 'name' => new external_value(PARAM_ALPHANUMEXT, 'data name'), 'value' => new external_value(PARAM_RAW, 'data value'), ] ), 'Preflight required data (like passwords)', VALUE_DEFAULT, [] ), 'forcenew' => new external_value(PARAM_BOOL, 'Whether to force a new attempt or not.', VALUE_DEFAULT, false), ] ); } /** * Starts a new attempt at a quiz. * * @param int $quizid quiz instance id * @param array $preflightdata preflight required data (like passwords) * @param bool $forcenew Whether to force a new attempt or not. * @return array of warnings and the attempt basic data * @since Moodle 3.1 */ public static function start_attempt($quizid, $preflightdata = [], $forcenew = false) { global $DB, $USER; $warnings = []; $attempt = []; $params = [ 'quizid' => $quizid, 'preflightdata' => $preflightdata, 'forcenew' => $forcenew, ]; $params = self::validate_parameters(self::start_attempt_parameters(), $params); $forcenew = $params['forcenew']; list($quiz, $course, $cm, $context) = self::validate_quiz($params['quizid']); $quizobj = quiz_settings::create($cm->instance, $USER->id); // Check questions. if (!$quizobj->has_questions()) { throw new moodle_exception('noquestionsfound', 'quiz', $quizobj->view_url()); } // Create an object to manage all the other (non-roles) access rules. $timenow = time(); $accessmanager = $quizobj->get_access_manager($timenow); // Validate permissions for creating a new attempt and start a new preview attempt if required. list($currentattemptid, $attemptnumber, $lastattempt, $messages, $page) = quiz_validate_new_attempt($quizobj, $accessmanager, $forcenew, -1, false); // Check access. if (!$quizobj->is_preview_user() && $messages) { // Create warnings with the exact messages. foreach ($messages as $message) { $warnings[] = [ 'item' => 'quiz', 'itemid' => $quiz->id, 'warningcode' => '1', 'message' => clean_text($message, PARAM_TEXT) ]; } } else { if ($accessmanager->is_preflight_check_required($currentattemptid)) { // Need to do some checks before allowing the user to continue. $provideddata = []; foreach ($params['preflightdata'] as $data) { $provideddata[$data['name']] = $data['value']; } $errors = $accessmanager->validate_preflight_check($provideddata, [], $currentattemptid); if (!empty($errors)) { throw new moodle_exception(array_shift($errors), 'quiz', $quizobj->view_url()); } // Pre-flight check passed. $accessmanager->notify_preflight_check_passed($currentattemptid); } if ($currentattemptid && $lastattempt->state !== quiz_attempt::NOT_STARTED) { if ($lastattempt->state == quiz_attempt::OVERDUE) { throw new moodle_exception('stateoverdue', 'quiz', $quizobj->view_url()); } else { throw new moodle_exception('attemptstillinprogress', 'quiz', $quizobj->view_url()); } } $offlineattempt = WS_SERVER ? true : false; $attempt = quiz_prepare_and_start_new_attempt($quizobj, $attemptnumber, $lastattempt, $offlineattempt); } $result = []; $result['attempt'] = $attempt; $result['warnings'] = $warnings; return $result; } /** * Describes the start_attempt return value. * * @return external_single_structure * @since Moodle 3.1 */ public static function start_attempt_returns() { return new external_single_structure( [ 'attempt' => self::attempt_structure(), 'warnings' => new external_warnings(), ] ); } /** * Utility function for validating a given attempt * * @param array $params array of parameters including the attemptid and preflight data * @param bool $checkaccessrules whether to check the quiz access rules or not * @param bool $failifoverdue whether to return error if the attempt is overdue * @return array containing the attempt object and access messages * @since Moodle 3.1 */ protected static function validate_attempt($params, $checkaccessrules = true, $failifoverdue = true) { global $USER; $attemptobj = quiz_attempt::create($params['attemptid']); $context = context_module::instance($attemptobj->get_cm()->id); self::validate_context($context); // Check that this attempt belongs to this user. if ($attemptobj->get_userid() != $USER->id) { throw new moodle_exception('notyourattempt', 'quiz', $attemptobj->view_url()); } // General capabilities check. $ispreviewuser = $attemptobj->is_preview_user(); if (!$ispreviewuser) { $attemptobj->require_capability('mod/quiz:attempt'); } // Check the access rules. $accessmanager = $attemptobj->get_access_manager(time()); $messages = []; if ($checkaccessrules) { // If the attempt is now overdue, or abandoned, deal with that. $attemptobj->handle_if_time_expired(time(), true); $messages = $accessmanager->prevent_access(); if (!$ispreviewuser && $messages) { throw new moodle_exception('attempterror', 'quiz', $attemptobj->view_url()); } } // Attempt closed?. if ($attemptobj->is_finished()) { throw new moodle_exception('attemptalreadyclosed', 'quiz', $attemptobj->view_url()); } else if ($failifoverdue && $attemptobj->get_state() == quiz_attempt::OVERDUE) { throw new moodle_exception('stateoverdue', 'quiz', $attemptobj->view_url()); } // User submitted data (like the quiz password). if ($accessmanager->is_preflight_check_required($attemptobj->get_attemptid())) { $provideddata = []; foreach ($params['preflightdata'] as $data) { $provideddata[$data['name']] = $data['value']; } $errors = $accessmanager->validate_preflight_check($provideddata, [], $params['attemptid']); if (!empty($errors)) { throw new moodle_exception(array_shift($errors), 'quiz', $attemptobj->view_url()); } // Pre-flight check passed. $accessmanager->notify_preflight_check_passed($params['attemptid']); } if (isset($params['page'])) { // Check if the page is out of range. if ($params['page'] != $attemptobj->force_page_number_into_range($params['page'])) { throw new moodle_exception('Invalid page number', 'quiz', $attemptobj->view_url()); } // Prevent out of sequence access. if (!$attemptobj->check_page_access($params['page'])) { throw new moodle_exception('Out of sequence access', 'quiz', $attemptobj->view_url()); } // Check slots. $slots = $attemptobj->get_slots($params['page']); if (empty($slots)) { throw new moodle_exception('noquestionsfound', 'quiz', $attemptobj->view_url()); } } return [$attemptobj, $messages]; } /** * Describes a single question structure. * * @return external_single_structure the question data. Some fields may not be returned depending on the quiz display settings. * @since Moodle 3.1 * @since Moodle 3.2 blockedbyprevious parameter added. */ private static function question_structure() { return new external_single_structure( [ 'slot' => new external_value(PARAM_INT, 'slot number'), 'type' => new external_value(PARAM_ALPHANUMEXT, 'question type, i.e: multichoice'), 'page' => new external_value(PARAM_INT, 'page of the quiz this question appears on'), 'questionnumber' => new external_value(PARAM_RAW, 'The question number to display for this question, e.g. "7", "i" or "Custom-B)".'), 'number' => new external_value(PARAM_INT, 'DO NOT USE. Use questionnumber. Only retained for backwards compatibility.', VALUE_OPTIONAL), 'html' => new external_value(PARAM_RAW, 'the question rendered'), 'responsefileareas' => new external_multiple_structure( new external_single_structure( [ 'area' => new external_value(PARAM_NOTAGS, 'File area name'), 'files' => new external_files('Response files for the question', VALUE_OPTIONAL), ] ), 'Response file areas including files', VALUE_OPTIONAL ), 'sequencecheck' => new external_value(PARAM_INT, 'the number of real steps in this attempt', VALUE_OPTIONAL), 'lastactiontime' => new external_value(PARAM_INT, 'the timestamp of the most recent step in this question attempt', VALUE_OPTIONAL), 'hasautosavedstep' => new external_value(PARAM_BOOL, 'whether this question attempt has autosaved data', VALUE_OPTIONAL), 'flagged' => new external_value(PARAM_BOOL, 'whether the question is flagged or not'), 'state' => new external_value(PARAM_ALPHA, 'the state where the question is in terms of correctness. It will not be returned if the user cannot see it due to the quiz display correctness settings.', VALUE_OPTIONAL), 'stateclass' => new external_value(PARAM_NOTAGS, 'A machine-readable class name for the state that this question attempt is in, as returned by question_usage_by_activity::get_question_state_class(). Always returned.', VALUE_OPTIONAL), 'status' => new external_value(PARAM_RAW, 'Human readable state of the question.', VALUE_OPTIONAL), 'blockedbyprevious' => new external_value(PARAM_BOOL, 'whether the question is blocked by the previous question', VALUE_OPTIONAL), 'mark' => new external_value(PARAM_RAW, 'the mark awarded. It will be returned only if the user is allowed to see it.', VALUE_OPTIONAL), 'maxmark' => new external_value(PARAM_FLOAT, 'the maximum mark possible for this question attempt. It will be returned only if the user is allowed to see it.', VALUE_OPTIONAL), 'settings' => new external_value(PARAM_RAW, 'Question settings (JSON encoded).', VALUE_OPTIONAL), ], 'The question data. Some fields may not be returned depending on the quiz display settings.' ); } /** * Return questions information for a given attempt. * * @param quiz_attempt $attemptobj the quiz attempt object * @param bool $review whether if we are in review mode or not * @param mixed $page string 'all' or integer page number * @return array array of questions including data */ private static function get_attempt_questions_data(quiz_attempt $attemptobj, $review, $page = 'all') { global $PAGE; $questions = []; $displayoptions = $attemptobj->get_display_options($review); $renderer = $PAGE->get_renderer('mod_quiz'); $contextid = $attemptobj->get_quizobj()->get_context()->id; foreach ($attemptobj->get_slots($page) as $slot) { $qtype = $attemptobj->get_question_type_name($slot); $qattempt = $attemptobj->get_question_attempt($slot); $questiondef = $qattempt->get_question(true); // Check display settings for question. $settings = $questiondef->get_question_definition_for_external_rendering($qattempt, $displayoptions); // Navigation information. $question = [ 'slot' => $slot, 'page' => $attemptobj->get_question_page($slot), 'questionnumber' => $attemptobj->get_question_number($slot), 'flagged' => $attemptobj->is_question_flagged($slot), 'sequencecheck' => $qattempt->get_sequence_check_count(), 'lastactiontime' => $qattempt->get_last_step()->get_timecreated(), 'hasautosavedstep' => $qattempt->has_autosaved_step(), ]; if ($question['questionnumber'] === (string) (int) $question['questionnumber']) { $question['number'] = $question['questionnumber']; } if ($attemptobj->is_real_question($slot)) { $showcorrectness = $displayoptions->correctness && $qattempt->has_marks(); if ($showcorrectness) { $question['state'] = (string) $attemptobj->get_question_state($slot); } // The stateclass is used for CSS classes but also for the lang strings. $question['stateclass'] = $attemptobj->get_question_state_class($slot, $displayoptions->correctness); $question['status'] = $attemptobj->get_question_status($slot, $displayoptions->correctness); $question['blockedbyprevious'] = $attemptobj->is_blocked_by_previous_question($slot); } if ($displayoptions->marks >= question_display_options::MAX_ONLY) { $question['maxmark'] = $qattempt->get_max_mark(); } if ($displayoptions->marks >= question_display_options::MARK_AND_MAX) { $question['mark'] = $attemptobj->get_question_mark($slot); } // Check access. This is needed especially when sequential navigation is enforced. To prevent the student see "future" questions. $haveaccess = $attemptobj->check_page_access($attemptobj->get_question_page($slot), false); if (!$haveaccess) { $question['type'] = ''; $question['html'] = ''; } // For visited pages/questions it is ok to keep data the user already saw. $questionalreadyseen = $attemptobj->get_currentpage() >= $attemptobj->get_question_page($slot); // Information when only the user has access to the question at any moment (free navigation) or already seen. if ($haveaccess || $questionalreadyseen) { // Get response files (for questions like essay that allows attachments). $responsefileareas = []; foreach (question_bank::get_qtype($qtype)->response_file_areas() as $area) { if ($files = $attemptobj->get_question_attempt($slot)->get_last_qt_files($area, $contextid)) { $responsefileareas[$area]['area'] = $area; $responsefileareas[$area]['files'] = []; foreach ($files as $file) { $responsefileareas[$area]['files'][] = [ 'filename' => $file->get_filename(), 'fileurl' => $qattempt->get_response_file_url($file), 'filesize' => $file->get_filesize(), 'filepath' => $file->get_filepath(), 'mimetype' => $file->get_mimetype(), 'timemodified' => $file->get_timemodified(), ]; } } } $question['type'] = $qtype; $question['html'] = $attemptobj->render_question($slot, $review, $renderer) . $PAGE->requires->get_end_code(); $question['responsefileareas'] = $responsefileareas; $question['settings'] = !empty($settings) ? json_encode($settings) : null; } $questions[] = $question; } return $questions; } /** * Describes the parameters for get_attempt_data. * * @return external_function_parameters * @since Moodle 3.1 */ public static function get_attempt_data_parameters() { return new external_function_parameters ( [ 'attemptid' => new external_value(PARAM_INT, 'attempt id'), 'page' => new external_value(PARAM_INT, 'page number'), 'preflightdata' => new external_multiple_structure( new external_single_structure( [ 'name' => new external_value(PARAM_ALPHANUMEXT, 'data name'), 'value' => new external_value(PARAM_RAW, 'data value'), ] ), 'Preflight required data (like passwords)', VALUE_DEFAULT, [] ) ] ); } /** * Returns information for the given attempt page for a quiz attempt in progress. * * @param int $attemptid attempt id * @param int $page page number * @param array $preflightdata preflight required data (like passwords) * @return array of warnings and the attempt data, next page, message and questions * @since Moodle 3.1 */ public static function get_attempt_data($attemptid, $page, $preflightdata = []) { global $PAGE; $warnings = []; $params = [ 'attemptid' => $attemptid, 'page' => $page, 'preflightdata' => $preflightdata, ]; $params = self::validate_parameters(self::get_attempt_data_parameters(), $params); [$attemptobj, $messages] = self::validate_attempt($params); if ($attemptobj->is_last_page($params['page'])) { $nextpage = -1; } else { $nextpage = $params['page'] + 1; } // TODO: Remove the code once the long-term solution (MDL-76728) has been applied. // Set a default URL to stop the debugging output. $PAGE->set_url('/fake/url'); $result = []; $result['attempt'] = $attemptobj->get_attempt(); $result['messages'] = $messages; $result['nextpage'] = $nextpage; $result['warnings'] = $warnings; $result['questions'] = self::get_attempt_questions_data($attemptobj, false, $params['page']); return $result; } /** * Describes the get_attempt_data return value. * * @return external_single_structure * @since Moodle 3.1 */ public static function get_attempt_data_returns() { return new external_single_structure( [ 'attempt' => self::attempt_structure(), 'messages' => new external_multiple_structure( new external_value(PARAM_TEXT, 'access message'), 'access messages, will only be returned for users with mod/quiz:preview capability, for other users this method will throw an exception if there are messages'), 'nextpage' => new external_value(PARAM_INT, 'next page number'), 'questions' => new external_multiple_structure(self::question_structure()), 'warnings' => new external_warnings(), ] ); } /** * Describes the parameters for get_attempt_summary. * * @return external_function_parameters * @since Moodle 3.1 */ public static function get_attempt_summary_parameters() { return new external_function_parameters ( [ 'attemptid' => new external_value(PARAM_INT, 'attempt id'), 'preflightdata' => new external_multiple_structure( new external_single_structure( [ 'name' => new external_value(PARAM_ALPHANUMEXT, 'data name'), 'value' => new external_value(PARAM_RAW, 'data value'), ] ), 'Preflight required data (like passwords)', VALUE_DEFAULT, [] ) ] ); } /** * Returns a summary of a quiz attempt before it is submitted. * * @param int $attemptid attempt id * @param int $preflightdata preflight required data (like passwords) * @return array of warnings and the attempt summary data for each question * @since Moodle 3.1 */ public static function get_attempt_summary($attemptid, $preflightdata = []) { $warnings = []; $params = [ 'attemptid' => $attemptid, 'preflightdata' => $preflightdata, ]; $params = self::validate_parameters(self::get_attempt_summary_parameters(), $params); list($attemptobj, $messages) = self::validate_attempt($params, true, false); $result = []; $result['warnings'] = $warnings; $result['questions'] = self::get_attempt_questions_data($attemptobj, false, 'all'); if ($attemptobj->get_state() == quiz_attempt::IN_PROGRESS && $attemptobj->get_quiz()->navmethod == 'free') { // Only count the unanswered question if the navigation method is set to free. $result['totalunanswered'] = $attemptobj->get_number_of_unanswered_questions(); } return $result; } /** * Describes the get_attempt_summary return value. * * @return external_single_structure * @since Moodle 3.1 */ public static function get_attempt_summary_returns() { return new external_single_structure( [ 'questions' => new external_multiple_structure(self::question_structure()), 'totalunanswered' => new external_value(PARAM_INT, 'Total unanswered questions.', VALUE_OPTIONAL), 'warnings' => new external_warnings(), ] ); } /** * Describes the parameters for save_attempt. * * @return external_function_parameters * @since Moodle 3.1 */ public static function save_attempt_parameters() { return new external_function_parameters ( [ 'attemptid' => new external_value(PARAM_INT, 'attempt id'), 'data' => new external_multiple_structure( new external_single_structure( [ 'name' => new external_value(PARAM_RAW, 'data name'), 'value' => new external_value(PARAM_RAW, 'data value'), ] ), 'the data to be saved' ), 'preflightdata' => new external_multiple_structure( new external_single_structure( [ 'name' => new external_value(PARAM_ALPHANUMEXT, 'data name'), 'value' => new external_value(PARAM_RAW, 'data value'), ] ), 'Preflight required data (like passwords)', VALUE_DEFAULT, [] ) ] ); } /** * Processes save requests during the quiz. This function is intended for the quiz auto-save feature. * * @param int $attemptid attempt id * @param array $data the data to be saved * @param array $preflightdata preflight required data (like passwords) * @return array of warnings and execution result * @since Moodle 3.1 */ public static function save_attempt($attemptid, $data, $preflightdata = []) { global $DB, $USER; $warnings = []; $params = [ 'attemptid' => $attemptid, 'data' => $data, 'preflightdata' => $preflightdata, ]; $params = self::validate_parameters(self::save_attempt_parameters(), $params); // Add a page, required by validate_attempt. list($attemptobj, $messages) = self::validate_attempt($params); // Prevent functions like file_get_submitted_draft_itemid() or form library requiring a sesskey for WS requests. if (WS_SERVER || PHPUNIT_TEST) { $USER->ignoresesskey = true; } $transaction = $DB->start_delegated_transaction(); // Create the $_POST object required by the question engine. $_POST = []; foreach ($data as $element) { $_POST[$element['name']] = $element['value']; // Some deep core functions like file_get_submitted_draft_itemid() also requires $_REQUEST to be filled. $_REQUEST[$element['name']] = $element['value']; } $timenow = time(); // Update the timemodifiedoffline field. $attemptobj->set_offline_modified_time($timenow); $attemptobj->process_auto_save($timenow); $transaction->allow_commit(); $result = []; $result['status'] = true; $result['warnings'] = $warnings; return $result; } /** * Describes the save_attempt return value. * * @return external_single_structure * @since Moodle 3.1 */ public static function save_attempt_returns() { return new external_single_structure( [ 'status' => new external_value(PARAM_BOOL, 'status: true if success'), 'warnings' => new external_warnings(), ] ); } /** * Describes the parameters for process_attempt. * * @return external_function_parameters * @since Moodle 3.1 */ public static function process_attempt_parameters() { return new external_function_parameters ( [ 'attemptid' => new external_value(PARAM_INT, 'attempt id'), 'data' => new external_multiple_structure( new external_single_structure( [ 'name' => new external_value(PARAM_RAW, 'data name'), 'value' => new external_value(PARAM_RAW, 'data value'), ] ), 'the data to be saved', VALUE_DEFAULT, [] ), 'finishattempt' => new external_value(PARAM_BOOL, 'whether to finish or not the attempt', VALUE_DEFAULT, false), 'timeup' => new external_value(PARAM_BOOL, 'whether the WS was called by a timer when the time is up', VALUE_DEFAULT, false), 'preflightdata' => new external_multiple_structure( new external_single_structure( [ 'name' => new external_value(PARAM_ALPHANUMEXT, 'data name'), 'value' => new external_value(PARAM_RAW, 'data value'), ] ), 'Preflight required data (like passwords)', VALUE_DEFAULT, [] ) ] ); } /** * Process responses during an attempt at a quiz and also deals with attempts finishing. * * @param int $attemptid attempt id * @param array $data the data to be saved * @param bool $finishattempt whether to finish or not the attempt * @param bool $timeup whether the WS was called by a timer when the time is up * @param array $preflightdata preflight required data (like passwords) * @return array of warnings and the attempt state after the processing * @since Moodle 3.1 */ public static function process_attempt($attemptid, $data, $finishattempt = false, $timeup = false, $preflightdata = []) { global $USER; $warnings = []; $params = [ 'attemptid' => $attemptid, 'data' => $data, 'finishattempt' => $finishattempt, 'timeup' => $timeup, 'preflightdata' => $preflightdata, ]; $params = self::validate_parameters(self::process_attempt_parameters(), $params); // Do not check access manager rules and evaluate fail if overdue. $attemptobj = quiz_attempt::create($params['attemptid']); $failifoverdue = !($attemptobj->get_quizobj()->get_quiz()->overduehandling == 'graceperiod'); list($attemptobj, $messages) = self::validate_attempt($params, false, $failifoverdue); // Prevent functions like file_get_submitted_draft_itemid() or form library requiring a sesskey for WS requests. if (WS_SERVER || PHPUNIT_TEST) { $USER->ignoresesskey = true; } // Create the $_POST object required by the question engine. $_POST = []; foreach ($params['data'] as $element) { $_POST[$element['name']] = $element['value']; $_REQUEST[$element['name']] = $element['value']; } $timenow = time(); $finishattempt = $params['finishattempt']; $timeup = $params['timeup']; $result = []; // Update the timemodifiedoffline field. $attemptobj->set_offline_modified_time($timenow); $result['state'] = $attemptobj->process_attempt($timenow, $finishattempt, $timeup, 0); $result['warnings'] = $warnings; return $result; } /** * Describes the process_attempt return value. * * @return external_single_structure * @since Moodle 3.1 */ public static function process_attempt_returns() { return new external_single_structure( [ 'state' => new external_value(PARAM_ALPHANUMEXT, 'state: the new attempt state: inprogress, finished, overdue, abandoned'), 'warnings' => new external_warnings(), ] ); } /** * Validate an attempt finished for review. The attempt would be reviewed by a user or a teacher. * * @param array $params Array of parameters including the attemptid * @return array containing the attempt object and display options * @since Moodle 3.1 */ protected static function validate_attempt_review($params) { $attemptobj = quiz_attempt::create($params['attemptid']); $attemptobj->check_review_capability(); $displayoptions = $attemptobj->get_display_options(true); if ($attemptobj->is_own_attempt()) { if (!$attemptobj->is_finished()) { throw new moodle_exception('attemptclosed', 'quiz', $attemptobj->view_url()); } else if (!$displayoptions->attempt) { throw new moodle_exception('noreview', 'quiz', $attemptobj->view_url(), null, $attemptobj->cannot_review_message()); } } else if (!$attemptobj->is_review_allowed()) { throw new moodle_exception('noreviewattempt', 'quiz', $attemptobj->view_url()); } return [$attemptobj, $displayoptions]; } /** * Describes the parameters for get_attempt_review. * * @return external_function_parameters * @since Moodle 3.1 */ public static function get_attempt_review_parameters() { return new external_function_parameters ( [ 'attemptid' => new external_value(PARAM_INT, 'attempt id'), 'page' => new external_value(PARAM_INT, 'page number, empty for all the questions in all the pages', VALUE_DEFAULT, -1), ] ); } /** * Returns review information for the given finished attempt, can be used by users or teachers. * * @param int $attemptid attempt id * @param int $page page number, empty for all the questions in all the pages * @return array of warnings and the attempt data, feedback and questions * @since Moodle 3.1 */ public static function get_attempt_review($attemptid, $page = -1) { $warnings = []; $params = [ 'attemptid' => $attemptid, 'page' => $page, ]; $params = self::validate_parameters(self::get_attempt_review_parameters(), $params); [$attemptobj, $displayoptions] = self::validate_attempt_review($params); if ($params['page'] !== -1) { $page = $attemptobj->force_page_number_into_range($params['page']); } else { $page = 'all'; } // Make sure all users associated to the attempt steps are loaded. Otherwise, this will // trigger a debugging message. $attemptobj->preload_all_attempt_step_users(); // Prepare the output. $result = []; $result['attempt'] = $attemptobj->get_attempt(); $result['questions'] = self::get_attempt_questions_data($attemptobj, true, $page, true); $result['additionaldata'] = []; // Summary data (from behaviours). $summarydata = $attemptobj->get_additional_summary_data($displayoptions); foreach ($summarydata as $key => $data) { // This text does not need formatting (no need for external_format_[string|text]). $result['additionaldata'][] = [ 'id' => $key, 'title' => $data['title'], $attemptobj->get_quizobj()->get_context()->id, 'content' => $data['content'], ]; } // Feedback if there is any, and the user is allowed to see it now. $grade = quiz_rescale_grade($attemptobj->get_attempt()->sumgrades, $attemptobj->get_quiz(), false); $feedback = $attemptobj->get_overall_feedback($grade); if ($displayoptions->overallfeedback && $feedback) { $result['additionaldata'][] = [ 'id' => 'feedback', 'title' => get_string('feedback', 'quiz'), 'content' => $feedback, ]; } if (!has_capability('mod/quiz:viewreports', $attemptobj->get_context()) && ($displayoptions->marks < question_display_options::MARK_AND_MAX || $attemptobj->get_attempt()->state != quiz_attempt::FINISHED)) { // Blank the mark if the teacher does not allow it. $result['attempt']->sumgrades = null; } else { $result['attempt']->gradeitemmarks = []; foreach ($attemptobj->get_grade_item_totals() as $gradeitem) { $result['attempt']->gradeitemmarks[] = [ 'name' => \core_external\util::format_string($gradeitem->name, $attemptobj->get_context()), 'grade' => $gradeitem->grade, 'maxgrade' => $gradeitem->maxgrade, ]; } } $result['grade'] = $grade; $result['warnings'] = $warnings; return $result; } /** * Describes the get_attempt_review return value. * * @return external_single_structure * @since Moodle 3.1 */ public static function get_attempt_review_returns() { return new external_single_structure( [ 'grade' => new external_value(PARAM_RAW, 'grade for the quiz (or empty or "notyetgraded")'), 'attempt' => self::attempt_structure(), 'additionaldata' => new external_multiple_structure( new external_single_structure( [ 'id' => new external_value(PARAM_ALPHANUMEXT, 'id of the data'), 'title' => new external_value(PARAM_TEXT, 'data title'), 'content' => new external_value(PARAM_RAW, 'data content'), ] ) ), 'questions' => new external_multiple_structure(self::question_structure()), 'warnings' => new external_warnings(), ] ); } /** * Describes the parameters for view_attempt. * * @return external_function_parameters * @since Moodle 3.1 */ public static function view_attempt_parameters() { return new external_function_parameters ( [ 'attemptid' => new external_value(PARAM_INT, 'attempt id'), 'page' => new external_value(PARAM_INT, 'page number'), 'preflightdata' => new external_multiple_structure( new external_single_structure( [ 'name' => new external_value(PARAM_ALPHANUMEXT, 'data name'), 'value' => new external_value(PARAM_RAW, 'data value'), ] ), 'Preflight required data (like passwords)', VALUE_DEFAULT, [] ) ] ); } /** * Trigger the attempt viewed event. * * @param int $attemptid attempt id * @param int $page page number * @param array $preflightdata preflight required data (like passwords) * @return array of warnings and status result * @since Moodle 3.1 */ public static function view_attempt($attemptid, $page, $preflightdata = []) { $warnings = []; $params = [ 'attemptid' => $attemptid, 'page' => $page, 'preflightdata' => $preflightdata, ]; $params = self::validate_parameters(self::view_attempt_parameters(), $params); list($attemptobj, $messages) = self::validate_attempt($params); // Log action. $attemptobj->fire_attempt_viewed_event(); // Update attempt page, throwing an exception if $page is not valid. if (!$attemptobj->set_currentpage($params['page'])) { throw new moodle_exception('Out of sequence access', 'quiz', $attemptobj->view_url()); } $result = []; $result['status'] = true; $result['warnings'] = $warnings; return $result; } /** * Describes the view_attempt return value. * * @return external_single_structure * @since Moodle 3.1 */ public static function view_attempt_returns() { return new external_single_structure( [ 'status' => new external_value(PARAM_BOOL, 'status: true if success'), 'warnings' => new external_warnings(), ] ); } /** * Describes the parameters for view_attempt_summary. * * @return external_function_parameters * @since Moodle 3.1 */ public static function view_attempt_summary_parameters() { return new external_function_parameters ( [ 'attemptid' => new external_value(PARAM_INT, 'attempt id'), 'preflightdata' => new external_multiple_structure( new external_single_structure( [ 'name' => new external_value(PARAM_ALPHANUMEXT, 'data name'), 'value' => new external_value(PARAM_RAW, 'data value'), ] ), 'Preflight required data (like passwords)', VALUE_DEFAULT, [] ) ] ); } /** * Trigger the attempt summary viewed event. * * @param int $attemptid attempt id * @param array $preflightdata preflight required data (like passwords) * @return array of warnings and status result * @since Moodle 3.1 */ public static function view_attempt_summary($attemptid, $preflightdata = []) { $warnings = []; $params = [ 'attemptid' => $attemptid, 'preflightdata' => $preflightdata, ]; $params = self::validate_parameters(self::view_attempt_summary_parameters(), $params); list($attemptobj, $messages) = self::validate_attempt($params); // Log action. $attemptobj->fire_attempt_summary_viewed_event(); $result = []; $result['status'] = true; $result['warnings'] = $warnings; return $result; } /** * Describes the view_attempt_summary return value. * * @return external_single_structure * @since Moodle 3.1 */ public static function view_attempt_summary_returns() { return new external_single_structure( [ 'status' => new external_value(PARAM_BOOL, 'status: true if success'), 'warnings' => new external_warnings(), ] ); } /** * Describes the parameters for view_attempt_review. * * @return external_function_parameters * @since Moodle 3.1 */ public static function view_attempt_review_parameters() { return new external_function_parameters ( [ 'attemptid' => new external_value(PARAM_INT, 'attempt id'), ] ); } /** * Trigger the attempt reviewed event. * * @param int $attemptid attempt id * @return array of warnings and status result * @since Moodle 3.1 */ public static function view_attempt_review($attemptid) { $warnings = []; $params = [ 'attemptid' => $attemptid, ]; $params = self::validate_parameters(self::view_attempt_review_parameters(), $params); list($attemptobj, $displayoptions) = self::validate_attempt_review($params); // Log action. $attemptobj->fire_attempt_reviewed_event(); $result = []; $result['status'] = true; $result['warnings'] = $warnings; return $result; } /** * Describes the view_attempt_review return value. * * @return external_single_structure * @since Moodle 3.1 */ public static function view_attempt_review_returns() { return new external_single_structure( [ 'status' => new external_value(PARAM_BOOL, 'status: true if success'), 'warnings' => new external_warnings(), ] ); } /** * Describes the parameters for view_quiz. * * @return external_function_parameters * @since Moodle 3.1 */ public static function get_quiz_feedback_for_grade_parameters() { return new external_function_parameters ( [ 'quizid' => new external_value(PARAM_INT, 'quiz instance id'), 'grade' => new external_value(PARAM_FLOAT, 'the grade to check'), ] ); } /** * Get the feedback text that should be show to a student who got the given grade in the given quiz. * * @param int $quizid quiz instance id * @param float $grade the grade to check * @return array of warnings and status result * @since Moodle 3.1 */ public static function get_quiz_feedback_for_grade($quizid, $grade) { global $DB; $params = [ 'quizid' => $quizid, 'grade' => $grade, ]; $params = self::validate_parameters(self::get_quiz_feedback_for_grade_parameters(), $params); $warnings = []; list($quiz, $course, $cm, $context) = self::validate_quiz($params['quizid']); $result = []; $result['feedbacktext'] = ''; $result['feedbacktextformat'] = FORMAT_MOODLE; $feedback = quiz_feedback_record_for_grade($params['grade'], $quiz); if (!empty($feedback->feedbacktext)) { list($text, $format) = \core_external\util::format_text( $feedback->feedbacktext, $feedback->feedbacktextformat, $context, 'mod_quiz', 'feedback', $feedback->id ); $result['feedbacktext'] = $text; $result['feedbacktextformat'] = $format; $feedbackinlinefiles = util::get_area_files($context->id, 'mod_quiz', 'feedback', $feedback->id); if (!empty($feedbackinlinefiles)) { $result['feedbackinlinefiles'] = $feedbackinlinefiles; } } $result['warnings'] = $warnings; return $result; } /** * Describes the get_quiz_feedback_for_grade return value. * * @return external_single_structure * @since Moodle 3.1 */ public static function get_quiz_feedback_for_grade_returns() { return new external_single_structure( [ 'feedbacktext' => new external_value(PARAM_RAW, 'the comment that corresponds to this grade (empty for none)'), 'feedbacktextformat' => new external_format_value('feedbacktext', VALUE_OPTIONAL), 'feedbackinlinefiles' => new external_files('feedback inline files', VALUE_OPTIONAL), 'warnings' => new external_warnings(), ] ); } /** * Describes the parameters for get_quiz_access_information. * * @return external_function_parameters * @since Moodle 3.1 */ public static function get_quiz_access_information_parameters() { return new external_function_parameters ( [ 'quizid' => new external_value(PARAM_INT, 'quiz instance id') ] ); } /** * Return access information for a given quiz. * * @param int $quizid quiz instance id * @return array of warnings and the access information * @since Moodle 3.1 */ public static function get_quiz_access_information($quizid) { global $DB, $USER; $warnings = []; $params = [ 'quizid' => $quizid ]; $params = self::validate_parameters(self::get_quiz_access_information_parameters(), $params); list($quiz, $course, $cm, $context) = self::validate_quiz($params['quizid']); $result = []; // Capabilities first. $result['canattempt'] = has_capability('mod/quiz:attempt', $context);; $result['canmanage'] = has_capability('mod/quiz:manage', $context);; $result['canpreview'] = has_capability('mod/quiz:preview', $context);; $result['canreviewmyattempts'] = has_capability('mod/quiz:reviewmyattempts', $context);; $result['canviewreports'] = has_capability('mod/quiz:viewreports', $context);; // Access manager now. $quizobj = quiz_settings::create($cm->instance, $USER->id); $ignoretimelimits = has_capability('mod/quiz:ignoretimelimits', $context, null, false); $timenow = time(); $accessmanager = new access_manager($quizobj, $timenow, $ignoretimelimits); $result['accessrules'] = $accessmanager->describe_rules(); $result['activerulenames'] = $accessmanager->get_active_rule_names(); $result['preventaccessreasons'] = $accessmanager->prevent_access(); $result['warnings'] = $warnings; return $result; } /** * Describes the get_quiz_access_information return value. * * @return external_single_structure * @since Moodle 3.1 */ public static function get_quiz_access_information_returns() { return new external_single_structure( [ 'canattempt' => new external_value(PARAM_BOOL, 'Whether the user can do the quiz or not.'), 'canmanage' => new external_value(PARAM_BOOL, 'Whether the user can edit the quiz settings or not.'), 'canpreview' => new external_value(PARAM_BOOL, 'Whether the user can preview the quiz or not.'), 'canreviewmyattempts' => new external_value(PARAM_BOOL, 'Whether the users can review their previous attempts or not.'), 'canviewreports' => new external_value(PARAM_BOOL, 'Whether the user can view the quiz reports or not.'), 'accessrules' => new external_multiple_structure( new external_value(PARAM_TEXT, 'rule description'), 'list of rules'), 'activerulenames' => new external_multiple_structure( new external_value(PARAM_PLUGIN, 'rule plugin names'), 'list of active rules'), 'preventaccessreasons' => new external_multiple_structure( new external_value(PARAM_TEXT, 'access restriction description'), 'list of reasons'), 'warnings' => new external_warnings(), ] ); } /** * Describes the parameters for get_attempt_access_information. * * @return external_function_parameters * @since Moodle 3.1 */ public static function get_attempt_access_information_parameters() { return new external_function_parameters ( [ 'quizid' => new external_value(PARAM_INT, 'quiz instance id'), 'attemptid' => new external_value(PARAM_INT, 'attempt id, 0 for the user last attempt if exists', VALUE_DEFAULT, 0), ] ); } /** * Return access information for a given attempt in a quiz. * * @param int $quizid quiz instance id * @param int $attemptid attempt id, 0 for the user last attempt if exists * @return array of warnings and the access information * @since Moodle 3.1 */ public static function get_attempt_access_information($quizid, $attemptid = 0) { global $DB, $USER; $warnings = []; $params = [ 'quizid' => $quizid, 'attemptid' => $attemptid, ]; $params = self::validate_parameters(self::get_attempt_access_information_parameters(), $params); list($quiz, $course, $cm, $context) = self::validate_quiz($params['quizid']); $attempttocheck = null; if (!empty($params['attemptid'])) { $attemptobj = quiz_attempt::create($params['attemptid']); if ($attemptobj->get_userid() != $USER->id) { throw new moodle_exception('notyourattempt', 'quiz', $attemptobj->view_url()); } $attempttocheck = $attemptobj->get_attempt(); } // Access manager now. $quizobj = quiz_settings::create($cm->instance, $USER->id); $ignoretimelimits = has_capability('mod/quiz:ignoretimelimits', $context, null, false); $timenow = time(); $accessmanager = new access_manager($quizobj, $timenow, $ignoretimelimits); $attempts = quiz_get_user_attempts($quiz->id, $USER->id, 'finished', true); $lastfinishedattempt = end($attempts); if ($unfinishedattempt = quiz_get_user_attempt_unfinished($quiz->id, $USER->id)) { $attempts[] = $unfinishedattempt; // Check if the attempt is now overdue. In that case the state will change. $quizobj->create_attempt_object($unfinishedattempt)->handle_if_time_expired(time(), false); if ($unfinishedattempt->state != quiz_attempt::IN_PROGRESS and $unfinishedattempt->state != quiz_attempt::OVERDUE) { $lastfinishedattempt = $unfinishedattempt; } } $numattempts = count($attempts); if (!$attempttocheck) { $attempttocheck = $unfinishedattempt ?: $lastfinishedattempt; } $result = []; $result['isfinished'] = $accessmanager->is_finished($numattempts, $lastfinishedattempt); $result['preventnewattemptreasons'] = $accessmanager->prevent_new_attempt($numattempts, $lastfinishedattempt); if ($attempttocheck) { $endtime = $accessmanager->get_end_time($attempttocheck); $result['endtime'] = ($endtime === false) ? 0 : $endtime; $attemptid = $unfinishedattempt ? $unfinishedattempt->id : null; $result['ispreflightcheckrequired'] = $accessmanager->is_preflight_check_required($attemptid); } $result['warnings'] = $warnings; return $result; } /** * Describes the get_attempt_access_information return value. * * @return external_single_structure * @since Moodle 3.1 */ public static function get_attempt_access_information_returns() { return new external_single_structure( [ 'endtime' => new external_value(PARAM_INT, 'When the attempt must be submitted (determined by rules).', VALUE_OPTIONAL), 'isfinished' => new external_value(PARAM_BOOL, 'Whether there is no way the user will ever be allowed to attempt.'), 'ispreflightcheckrequired' => new external_value(PARAM_BOOL, 'whether a check is required before the user starts/continues his attempt.', VALUE_OPTIONAL), 'preventnewattemptreasons' => new external_multiple_structure( new external_value(PARAM_TEXT, 'access restriction description'), 'list of reasons'), 'warnings' => new external_warnings(), ] ); } /** * Describes the parameters for get_quiz_required_qtypes. * * @return external_function_parameters * @since Moodle 3.1 */ public static function get_quiz_required_qtypes_parameters() { return new external_function_parameters ( [ 'quizid' => new external_value(PARAM_INT, 'quiz instance id') ] ); } /** * Return the potential question types that would be required for a given quiz. * Please note that for random question types we return the potential question types in the category choosen. * * @param int $quizid quiz instance id * @return array of warnings and the access information * @since Moodle 3.1 */ public static function get_quiz_required_qtypes($quizid) { global $DB, $USER; $warnings = []; $params = [ 'quizid' => $quizid ]; $params = self::validate_parameters(self::get_quiz_required_qtypes_parameters(), $params); list($quiz, $course, $cm, $context) = self::validate_quiz($params['quizid']); $quizobj = quiz_settings::create($cm->instance, $USER->id); $quizobj->preload_questions(); // Question types used. $result = []; $result['questiontypes'] = $quizobj->get_all_question_types_used(true); $result['warnings'] = $warnings; return $result; } /** * Describes the get_quiz_required_qtypes return value. * * @return external_single_structure * @since Moodle 3.1 */ public static function get_quiz_required_qtypes_returns() { return new external_single_structure( [ 'questiontypes' => new external_multiple_structure( new external_value(PARAM_PLUGIN, 'question type'), 'list of question types used in the quiz'), 'warnings' => new external_warnings(), ] ); } } PK9B\Yn classes/form/add_random_form.phpnu[. namespace mod_quiz\form; use core\check\performance\debugging; use core_tag_tag; use moodleform; defined('MOODLE_INTERNAL') || die(); require_once($CFG->libdir.'/formslib.php'); /** * The add random questions form. * * @package mod_quiz * @copyright 1999 onwards Martin Dougiamas and others {@link http://moodle.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @deprecated Moodle 4.3 MDL-72321. This form is new generated in a modal with mod_quiz/add_random_question_form.mustache * @todo Final deprecation in Moodle 4.7 MDL-78091 */ class add_random_form extends moodleform { /** * Deprecated. * * @return void * @deprecated Moodle 4.3 MDL-72321 * @todo Final deprecation in Moodle 4.7 MDL-78091 */ protected function definition() { debugging( 'add_random_form is deprecated. Please use mod_quiz/add_random_question_form.mustache instead.', DEBUG_DEVELOPER ); global $OUTPUT, $PAGE, $CFG; $mform = $this->_form; $mform->setDisableShortforms(); $contexts = $this->_customdata['contexts']; $usablecontexts = $contexts->having_cap('moodle/question:useall'); // Random from existing category section. $mform->addElement('header', 'existingcategoryheader', get_string('randomfromexistingcategory', 'quiz')); $mform->addElement('questioncategory', 'category', get_string('category'), ['contexts' => $usablecontexts, 'top' => true]); $mform->setDefault('category', $this->_customdata['cat']); $mform->addElement('checkbox', 'includesubcategories', '', get_string('recurse', 'quiz')); $tops = question_get_top_categories_for_contexts(array_column($contexts->all(), 'id')); $mform->hideIf('includesubcategories', 'category', 'in', $tops); if ($CFG->usetags) { $tagstrings = []; $tags = core_tag_tag::get_tags_by_area_in_contexts('core_question', 'question', $usablecontexts); foreach ($tags as $tag) { $tagstrings["{$tag->id},{$tag->name}"] = $tag->name; } $options = [ 'multiple' => true, 'noselectionstring' => get_string('anytags', 'quiz'), ]; $mform->addElement('autocomplete', 'fromtags', get_string('randomquestiontags', 'mod_quiz'), $tagstrings, $options); $mform->addHelpButton('fromtags', 'randomquestiontags', 'mod_quiz'); } // TODO: in the past, the drop-down used to only show sensible choices for // number of questions to add. That is, if the currently selected filter // only matched 9 questions (not already in the quiz), then the drop-down would // only offer choices 1..9. This nice UI hint got lost when the UI became Ajax-y. // We should add it back. $mform->addElement('select', 'numbertoadd', get_string('randomnumber', 'quiz'), $this->get_number_of_questions_to_add_choices()); $previewhtml = $OUTPUT->render_from_template('mod_quiz/random_question_form_preview', []); $mform->addElement('html', $previewhtml); $mform->addElement('submit', 'existingcategory', get_string('addrandomquestion', 'quiz')); // If the manage categories plugins is enabled, add the elements to create a new category in the form. if (\core\plugininfo\qbank::is_plugin_enabled(\qbank_managecategories\helper::PLUGINNAME)) { // Random from a new category section. $mform->addElement('header', 'newcategoryheader', get_string('randomquestionusinganewcategory', 'quiz')); $mform->addElement('text', 'name', get_string('name'), 'maxlength="254" size="50"'); $mform->setType('name', PARAM_TEXT); $mform->addElement('questioncategory', 'parent', get_string('parentcategory', 'question'), ['contexts' => $usablecontexts, 'top' => true]); $mform->addHelpButton('parent', 'parentcategory', 'question'); $mform->addElement('submit', 'newcategory', get_string('createcategoryandaddrandomquestion', 'quiz')); } // Cancel button. $mform->addElement('cancel'); $mform->closeHeaderBefore('cancel'); $mform->addElement('hidden', 'addonpage', 0, 'id="rform_qpage"'); $mform->setType('addonpage', PARAM_SEQUENCE); $mform->addElement('hidden', 'cmid', 0); $mform->setType('cmid', PARAM_INT); $mform->addElement('hidden', 'returnurl', 0); $mform->setType('returnurl', PARAM_LOCALURL); // Add the javascript required to enhance this mform. $PAGE->requires->js_call_amd('mod_quiz/add_random_form', 'init', [ $mform->getAttribute('id'), $contexts->lowest()->id, $tops, $CFG->usetags ]); } /** * Deprecated. * * @param array $fromform * @param array $files * @return array * @deprecated Moodle 4.3 MDL-72321 * @todo Final deprecation in Moodle 4.7 MDL-78091 */ public function validation($fromform, $files) { debugging( 'add_random_form is deprecated. Please use mod_quiz/add_random_question_form.mustache instead.', DEBUG_DEVELOPER ); $errors = parent::validation($fromform, $files); if (!empty($fromform['newcategory']) && trim($fromform['name']) == '') { $errors['name'] = get_string('categorynamecantbeblank', 'question'); } return $errors; } /** * Return an arbitrary array for the dropdown menu * * @param int $maxrand * @return array of integers [1, 2, ..., 100] (or to the smaller of $maxrand and 100.) */ private function get_number_of_questions_to_add_choices($maxrand = 100) { $randomcount = []; for ($i = 1; $i <= min(100, $maxrand); $i++) { $randomcount[$i] = $i; } return $randomcount; } } PK9B\ *%classes/form/preflight_check_form.phpnu[. namespace mod_quiz\form; use moodleform; defined('MOODLE_INTERNAL') || die(); require_once($CFG->libdir.'/formslib.php'); /** * A form that limits student's access to attempt a quiz. * * @package mod_quiz * @copyright 2011 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class preflight_check_form extends moodleform { protected function definition() { $mform = $this->_form; $this->_form->updateAttributes(['id' => 'mod_quiz_preflight_form']); foreach ($this->_customdata['hidden'] as $name => $value) { if ($name === 'sesskey') { continue; } $mform->addElement('hidden', $name, $value); $mform->setType($name, PARAM_INT); } foreach ($this->_customdata['rules'] as $rule) { if ($rule->is_preflight_check_required($this->_customdata['attemptid'])) { $rule->add_preflight_check_form_fields($this, $mform, $this->_customdata['attemptid']); } } $this->add_action_buttons(true, get_string('startattempt', 'quiz')); $this->set_display_vertical(); $mform->setDisableShortforms(); } public function validation($data, $files): array { $errors = parent::validation($data, $files); $accessmanager = $this->_customdata['quizobj']->get_access_manager(time()); return array_merge($errors, $accessmanager->validate_preflight_check( $data, $files, $this->_customdata['attemptid'])); } } PK9B\b//#classes/form/edit_override_form.phpnu[. namespace mod_quiz\form; use cm_info; use context; use context_module; use mod_quiz_mod_form; use moodle_url; use moodleform; use stdClass; defined('MOODLE_INTERNAL') || die(); require_once($CFG->libdir . '/formslib.php'); require_once($CFG->dirroot . '/mod/quiz/mod_form.php'); /** * Form for editing quiz settings overrides. * * @package mod_quiz * @copyright 2010 Matt Petro * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class edit_override_form extends moodleform { /** @var cm_info course module object. */ protected $cm; /** @var stdClass the quiz settings object. */ protected $quiz; /** @var context_module the quiz context. */ protected $context; /** @var bool editing group override (true) or user override (false). */ protected $groupmode; /** @var int groupid, if provided. */ protected $groupid; /** @var int userid, if provided. */ protected $userid; /** @var int overrideid, if provided. */ protected int $overrideid; /** * Constructor. * * @param moodle_url $submiturl the form action URL. * @param cm_info $cm course module object. * @param stdClass $quiz the quiz settings object. * @param context_module $context the quiz context. * @param bool $groupmode editing group override (true) or user override (false). * @param stdClass|null $override the override being edited, if it already exists. */ public function __construct(moodle_url $submiturl, cm_info $cm, stdClass $quiz, context_module $context, bool $groupmode, ?stdClass $override) { $this->cm = $cm; $this->quiz = $quiz; $this->context = $context; $this->groupmode = $groupmode; $this->groupid = empty($override->groupid) ? 0 : $override->groupid; $this->userid = empty($override->userid) ? 0 : $override->userid; $this->overrideid = $override->id ?? 0; parent::__construct($submiturl); } protected function definition() { global $DB; $cm = $this->cm; $mform = $this->_form; $mform->addElement('header', 'override', get_string('override', 'quiz')); $quizgroupmode = groups_get_activity_groupmode($cm); $accessallgroups = ($quizgroupmode == NOGROUPS) || has_capability('moodle/site:accessallgroups', $this->context); if ($this->groupmode) { // Group override. if ($this->groupid) { // There is already a groupid, so freeze the selector. $groupchoices = [ $this->groupid => format_string(groups_get_group_name($this->groupid), true, ['context' => $this->context]), ]; $mform->addElement('select', 'groupid', get_string('overridegroup', 'quiz'), $groupchoices); $mform->freeze('groupid'); } else { // Prepare the list of groups. // Only include the groups the current can access. $groups = $accessallgroups ? groups_get_all_groups($cm->course) : groups_get_activity_allowed_groups($cm); if (empty($groups)) { // Generate an error. $link = new moodle_url('/mod/quiz/overrides.php', ['cmid' => $cm->id]); throw new \moodle_exception('groupsnone', 'quiz', $link); } $groupchoices = []; foreach ($groups as $group) { if ($group->visibility != GROUPS_VISIBILITY_NONE) { $groupchoices[$group->id] = format_string($group->name, true, ['context' => $this->context]); } } unset($groups); if (count($groupchoices) == 0) { $groupchoices[0] = get_string('none'); } $mform->addElement('select', 'groupid', get_string('overridegroup', 'quiz'), $groupchoices); $mform->addRule('groupid', get_string('required'), 'required', null, 'client'); } } else { // User override. $userfieldsapi = \core_user\fields::for_identity($this->context)->with_userpic()->with_name(); $extrauserfields = $userfieldsapi->get_required_fields([\core_user\fields::PURPOSE_IDENTITY]); if ($this->userid) { // There is already a userid, so freeze the selector. $user = $DB->get_record('user', ['id' => $this->userid]); profile_load_custom_fields($user); $userchoices = []; $userchoices[$this->userid] = self::display_user_name($user, $extrauserfields); $mform->addElement('select', 'userid', get_string('overrideuser', 'quiz'), $userchoices); $mform->freeze('userid'); } else { // Prepare the list of users. $groupids = 0; if (!$accessallgroups) { $groups = groups_get_activity_allowed_groups($cm); $groupids = array_keys($groups); } $enrolledjoin = get_enrolled_with_capabilities_join( $this->context, '', 'mod/quiz:attempt', $groupids, true); $userfieldsql = $userfieldsapi->get_sql('u', true, '', '', false); list($sort, $sortparams) = users_order_by_sql('u', null, $this->context, $userfieldsql->mappings); $users = $DB->get_records_sql(" SELECT DISTINCT $userfieldsql->selects FROM {user} u $enrolledjoin->joins $userfieldsql->joins LEFT JOIN {quiz_overrides} existingoverride ON existingoverride.userid = u.id AND existingoverride.quiz = :quizid WHERE existingoverride.id IS NULL AND $enrolledjoin->wheres ORDER BY $sort ", array_merge(['quizid' => $this->quiz->id], $userfieldsql->params, $enrolledjoin->params, $sortparams)); // Filter users based on any fixed restrictions (groups, profile). $info = new \core_availability\info_module($cm); $users = $info->filter_user_list($users); if (empty($users)) { // Generate an error. $link = new moodle_url('/mod/quiz/overrides.php', ['cmid' => $cm->id]); throw new \moodle_exception('usersnone', 'quiz', $link); } $userchoices = []; foreach ($users as $id => $user) { $userchoices[$id] = self::display_user_name($user, $extrauserfields); } unset($users); $mform->addElement('searchableselector', 'userid', get_string('overrideuser', 'quiz'), $userchoices); $mform->addRule('userid', get_string('required'), 'required', null, 'client'); } } // Password. // This field has to be above the date and timelimit fields, // otherwise browsers will clear it when those fields are changed. $mform->addElement('passwordunmask', 'password', get_string('requirepassword', 'quiz')); $mform->setType('password', PARAM_TEXT); $mform->addHelpButton('password', 'requirepassword', 'quiz'); $mform->setDefault('password', $this->quiz->password); // Open and close dates. $mform->addElement('date_time_selector', 'timeopen', get_string('quizopen', 'quiz'), mod_quiz_mod_form::$datefieldoptions); $mform->setDefault('timeopen', $this->quiz->timeopen); $mform->addElement('date_time_selector', 'timeclose', get_string('quizclose', 'quiz'), mod_quiz_mod_form::$datefieldoptions); $mform->setDefault('timeclose', $this->quiz->timeclose); // Time limit. $mform->addElement('duration', 'timelimit', get_string('timelimit', 'quiz'), ['optional' => true]); $mform->addHelpButton('timelimit', 'timelimit', 'quiz'); $mform->setDefault('timelimit', $this->quiz->timelimit); // Number of attempts. $attemptoptions = ['0' => get_string('unlimited')]; for ($i = 1; $i <= QUIZ_MAX_ATTEMPT_OPTION; $i++) { $attemptoptions[$i] = $i; } $mform->addElement('select', 'attempts', get_string('attemptsallowed', 'quiz'), $attemptoptions); $mform->addHelpButton('attempts', 'attempts', 'quiz'); $mform->setDefault('attempts', $this->quiz->attempts); // Submit buttons. $mform->addElement('submit', 'resetbutton', get_string('reverttodefaults', 'quiz')); $buttonarray = []; $buttonarray[] = $mform->createElement('submit', 'submitbutton', get_string('save', 'quiz')); $buttonarray[] = $mform->createElement('submit', 'againbutton', get_string('saveoverrideandstay', 'quiz')); $buttonarray[] = $mform->createElement('cancel'); $mform->addGroup($buttonarray, 'buttonbar', '', [' '], false); $mform->closeHeaderBefore('buttonbar'); } /** * Get a user's name and identity ready to display. * * @param stdClass $user a user object. * @param array $extrauserfields (identity fields in user table only from the user_fields API) * @return string User's name, with extra info, for display. */ public static function display_user_name(stdClass $user, array $extrauserfields): string { $username = fullname($user); $namefields = []; foreach ($extrauserfields as $field) { if (isset($user->$field) && $user->$field !== '') { $namefields[] = s($user->$field); } else if (strpos($field, 'profile_field_') === 0) { $field = substr($field, 14); if (isset($user->profile[$field]) && $user->profile[$field] !== '') { $namefields[] = s($user->profile[$field]); } } } if ($namefields) { $username .= ' (' . implode(', ', $namefields) . ')'; } return $username; } /** * Validate the data from the form. * * @param array $data form data * @param array $files form files * @return array An array of error messages, where the key is is the mform element name and the value is the error. */ public function validation($data, $files): array { $errors = parent::validation($data, $files); $data['id'] = $this->overrideid; $data['quiz'] = $this->quiz->id; $manager = new \mod_quiz\local\override_manager($this->quiz, $this->context); $errors = array_merge($errors, $manager->validate_data($data)); // Any 'general' errors we merge with the group/user selector element. if (!empty($errors['general'])) { if ($this->groupmode) { $errors['groupid'] = $errors['groupid'] ?? "" . $errors['general']; } else { $errors['userid'] = $errors['userid'] ?? "" . $errors['general']; } } return $errors; } } PK9B\RCC4classes/external/get_reopen_attempt_confirmation.phpnu[. namespace mod_quiz\external; use core_external\external_api; use core_external\external_description; use core_external\external_function_parameters; use core_external\external_value; use Exception; use html_writer; use mod_quiz\quiz_attempt; use moodle_exception; /** * Web service to check a quiz attempt state, and return a confirmation message if it can be reopened now. * * The use must have the 'mod/quiz:reopenattempts' capability and the attempt * must (at least for now) be in the 'Never submitted' state (quiz_attempt::ABANDONED). * * @package mod_quiz * @copyright 2023 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class get_reopen_attempt_confirmation extends external_api { /** * Declare the method parameters. * * @return external_function_parameters */ public static function execute_parameters(): external_function_parameters { return new external_function_parameters([ 'attemptid' => new external_value(PARAM_INT, 'The id of the attempt to reopen'), ]); } /** * Check a quiz attempt state, and return a confirmation message method implementation. * * @param int $attemptid the id of the attempt to reopen. * @return string a suitable confirmation message (HTML), if the attempt is suitable to be reopened. * @throws Exception an appropriate exception if the attempt cannot be reopened now. */ public static function execute(int $attemptid): string { global $DB; ['attemptid' => $attemptid] = self::validate_parameters( self::execute_parameters(), ['attemptid' => $attemptid]); // Check the request is valid. $attemptobj = quiz_attempt::create($attemptid); require_capability('mod/quiz:reopenattempts', $attemptobj->get_context()); self::validate_context($attemptobj->get_context()); if ($attemptobj->get_state() != quiz_attempt::ABANDONED) { throw new moodle_exception('reopenattemptwrongstate', 'quiz', '', ['attemptid' => $attemptid, 'state' => quiz_attempt_state_name($attemptobj->get_state())]); } // Work out what the affect or re-opening will be. $timestamp = time(); $timeclose = $attemptobj->get_access_manager(time())->get_end_time($attemptobj->get_attempt()); if ($timeclose && $timestamp > $timeclose) { $expectedoutcome = get_string('reopenedattemptwillbesubmitted', 'quiz'); } else if ($timeclose) { $expectedoutcome = get_string('reopenedattemptwillbeinprogressuntil', 'quiz', userdate($timeclose)); } else { $expectedoutcome = get_string('reopenedattemptwillbeinprogress', 'quiz'); } // Return the required message. $user = $DB->get_record('user', ['id' => $attemptobj->get_userid()], '*', MUST_EXIST); return html_writer::tag('p', get_string('reopenattemptareyousuremessage', 'quiz', ['attemptnumber' => $attemptobj->get_attempt_number(), 'attemptuser' => s(fullname($user))])) . html_writer::tag('p', $expectedoutcome); } /** * Define the webservice response. * * @return external_description */ public static function execute_returns(): external_description { return new external_value(PARAM_RAW, 'Confirmation to show the user before the attempt is reopened.'); } } PK9B\"χ^^!classes/external/update_slots.phpnu[. namespace mod_quiz\external; use core_external\external_api; use core_external\external_description; use core_external\external_function_parameters; use core_external\external_multiple_structure; use core_external\external_single_structure; use core_external\external_value; use mod_quiz\quiz_attempt; use mod_quiz\quiz_settings; use moodle_exception; /** * Web service method to update the properties of one or more slots in a quiz. * * The user must have the 'mod/quiz:manage' capability for the quiz. * * All the properties that can be set are optional. Only the ones passed are changed. * The full properties of the updated slot are returned. * * @package mod_quiz * @copyright 2023 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class update_slots extends external_api { /** * Declare the method parameters. * * @return external_function_parameters */ public static function execute_parameters(): external_function_parameters { return new external_function_parameters([ 'quizid' => new external_value(PARAM_INT, 'The quiz to update slots for.'), 'slots' => new external_multiple_structure( new external_single_structure([ 'id' => new external_value(PARAM_INT, 'id of the slot'), 'displaynumber' => new external_value( PARAM_TEXT, 'If passed, new customised question number. Empty string to clear customisation. ' . 'Null, or not specified, to leave unchanged.', VALUE_OPTIONAL ), 'requireprevious' => new external_value( PARAM_BOOL, 'Whether to make this slot dependent on the previous one. Null, or not specified, to leave unchanged.', VALUE_OPTIONAL ), 'maxmark' => new external_value( PARAM_FLOAT, 'Mark that this questions is out of. Null, or not specified, to leave unchanged.', VALUE_OPTIONAL ), 'quizgradeitemid' => new external_value( PARAM_INT, 'For quizzes with multiple grades, which grade this slot contributes to (quiz_grade_id). ' . '0 to set to nothing. Null, or not specified, to leave unchanged.', VALUE_OPTIONAL ), ]) ), ]); } /** * Update the properties of one or more slots in a quiz. * * @param int $quizid the id of the quiz to update slots in. * @param array $slotsdata list of slots update. Must have properties id, any any other properties to change. */ public static function execute(int $quizid, array $slotsdata): void { global $DB; [ 'quizid' => $quizid, 'slots' => $slotsdata, ] = self::validate_parameters(self::execute_parameters(), [ 'quizid' => $quizid, 'slots' => $slotsdata, ]); // Check the request is valid. $quizobj = quiz_settings::create($quizid); require_capability('mod/quiz:manage', $quizobj->get_context()); self::validate_context($quizobj->get_context()); $transaction = $DB->start_delegated_transaction(); $structure = $quizobj->get_structure(); $gradingsetupchanged = false; foreach ($slotsdata as $slotdata) { // Check this slot exists in this quiz. $slot = $structure->get_slot_by_id($slotdata['id']); if (isset($slotdata['displaynumber'])) { $structure->update_slot_display_number($slot->id, $slotdata['displaynumber']); } if (isset($slotdata['requireprevious'])) { $structure->update_question_dependency($slot->id, $slotdata['requireprevious']); } if (isset($slotdata['maxmark'])) { $gradingsetupchanged = $structure->update_slot_maxmark($slot, $slotdata['maxmark']) || $gradingsetupchanged; } if (array_key_exists('quizgradeitemid', $slotdata)) { $gradingsetupchanged = $structure->update_slot_grade_item($slot, $slotdata['quizgradeitemid']) || $gradingsetupchanged; } } // If the grade setup has canged, recompute things. if ($gradingsetupchanged) { $gradecalculator = $quizobj->get_grade_calculator(); quiz_delete_previews($quizobj->get_quiz()); $gradecalculator->recompute_quiz_sumgrades(); $gradecalculator->recompute_all_attempt_sumgrades(); $gradecalculator->recompute_all_final_grades(); quiz_update_grades($quizobj->get_quiz(), 0, true); } $transaction->allow_commit(); } /** * Define the webservice response. * * @return external_description|null always null. */ public static function execute_returns(): ?external_description { return null; } } PK9B\-.)classes/external/add_random_questions.phpnu[. namespace mod_quiz\external; defined('MOODLE_INTERNAL') || die(); require_once($CFG->libdir . '/externallib.php'); require_once($CFG->dirroot . '/question/editlib.php'); require_once($CFG->dirroot . '/mod/quiz/locallib.php'); use external_function_parameters; use external_single_structure; use external_value; use external_api; use core_question\category_manager; use mod_quiz\question\bank\filter\custom_category_condition; use mod_quiz\quiz_settings; use mod_quiz\structure; /** * Add random questions to a quiz. * * @package mod_quiz * @copyright 2022 Catalyst IT Australia Pty Ltd * @author Nathan Nguyen * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class add_random_questions extends external_api { /** * Parameters for the web service function * * @return external_function_parameters */ public static function execute_parameters(): external_function_parameters { return new external_function_parameters ( [ 'cmid' => new external_value(PARAM_INT, 'The cmid of the quiz'), 'addonpage' => new external_value(PARAM_INT, 'The page where random questions will be added to'), 'randomcount' => new external_value(PARAM_INT, 'Number of random questions'), 'filtercondition' => new external_value( PARAM_TEXT, '(Optional) The filter condition used when adding random questions from an existing category. Not required if adding random questions from a new category.', VALUE_DEFAULT, '', ), 'newcategory' => new external_value( PARAM_TEXT, '(Optional) The name of a new question category to create and use for the random questions.', VALUE_DEFAULT, '', ), 'parentcategory' => new external_value( PARAM_TEXT, '(Optional) The parent of the new question category, if creating one.', VALUE_DEFAULT, 0, ), ] ); } /** * Add random questions. * * @param int $cmid The cmid of the quiz * @param int $addonpage The page where random questions will be added to * @param int $randomcount Number of random questions * @param string $filtercondition Filter condition * @param string $newcategory add new category * @param string $parentcategory parent category of new category * @return array result */ public static function execute( int $cmid, int $addonpage, int $randomcount, string $filtercondition = '', string $newcategory = '', string $parentcategory = '', ): array { [ 'cmid' => $cmid, 'addonpage' => $addonpage, 'randomcount' => $randomcount, 'filtercondition' => $filtercondition, 'newcategory' => $newcategory, 'parentcategory' => $parentcategory, ] = self::validate_parameters(self::execute_parameters(), [ 'cmid' => $cmid, 'addonpage' => $addonpage, 'randomcount' => $randomcount, 'filtercondition' => $filtercondition, 'newcategory' => $newcategory, 'parentcategory' => $parentcategory, ]); // Validate context. $thiscontext = \context_module::instance($cmid); self::validate_context($thiscontext); require_capability('mod/quiz:manage', $thiscontext); // If filtercondition is not empty, decode it. Otherwise, set it to empty array. $filtercondition = !empty($filtercondition) ? json_decode($filtercondition, true) : []; // Create new category. if (!empty($newcategory)) { $categorymanager = new category_manager(); $categoryid = $categorymanager->add_category($parentcategory, $newcategory, ''); $filter = [ 'category' => [ 'jointype' => custom_category_condition::JOINTYPE_DEFAULT, 'values' => [$categoryid], 'filteroptions' => ['includesubcategories' => false], ] ]; // Generate default filter condition for the random question to be added in the new category. $filtercondition = [ 'qpage' => 0, 'cat' => "{$categoryid},{$thiscontext->id}", 'qperpage' => DEFAULT_QUESTIONS_PER_PAGE, 'tabname' => 'questions', 'sortdata' => [], 'filter' => $filter, ]; } // Add random question to the quiz. [$quiz, ] = get_module_from_cmid($cmid); $settings = quiz_settings::create_for_cmid($cmid); $structure = structure::create_for_quiz($settings); $structure->add_random_questions($addonpage, $randomcount, $filtercondition); quiz_delete_previews($quiz); quiz_settings::create($quiz->id)->get_grade_calculator()->recompute_quiz_sumgrades(); return ['message' => get_string('addarandomquestion_success', 'mod_quiz')]; } /** * Returns description of method result value. * * @return external_value */ public static function execute_returns() { return new external_single_structure([ 'message' => new external_value(PARAM_TEXT, 'Message', VALUE_OPTIONAL) ]); } } PK9B\aݘ  'classes/external/delete_grade_items.phpnu[. namespace mod_quiz\external; use core_external\external_api; use core_external\external_description; use core_external\external_function_parameters; use core_external\external_multiple_structure; use core_external\external_single_structure; use core_external\external_value; use mod_quiz\quiz_attempt; use mod_quiz\quiz_settings; use moodle_exception; /** * Web service method to delete quiz grade items. * * The user must have the 'mod/quiz:manage' capability for the quiz. * * The grade items to be deleted must all belong to the same quiz, * and must not be referred to by any slot. * * @package mod_quiz * @copyright 2023 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class delete_grade_items extends external_api { /** * Declare the method parameters. * * @return external_function_parameters */ public static function execute_parameters(): external_function_parameters { return new external_function_parameters([ 'quizid' => new external_value(PARAM_INT, 'The quiz to update slots for.'), 'quizgradeitems' => new external_multiple_structure( new external_single_structure([ 'id' => new external_value(PARAM_INT, 'id of the quiz grade item'), ]) ), ]); } /** * Delete quiz grade items, if they are unused. * * @param int $quizid the id of the quiz from which to dlete grade items. * @param array $gradeitems list of grade items to delete. (They must belong to this quiz.) */ public static function execute(int $quizid, array $gradeitems): void { global $DB; [ 'quizid' => $quizid, 'quizgradeitems' => $gradeitems, ] = self::validate_parameters(self::execute_parameters(), [ 'quizid' => $quizid, 'quizgradeitems' => $gradeitems, ]); // Check the request is valid. $quizobj = quiz_settings::create($quizid); require_capability('mod/quiz:manage', $quizobj->get_context()); self::validate_context($quizobj->get_context()); $transaction = $DB->start_delegated_transaction(); $structure = $quizobj->get_structure(); foreach ($gradeitems as $gradeitemdata) { $structure->delete_grade_item($gradeitemdata['id']); } $transaction->allow_commit(); } /** * Define the webservice response. * * @return external_description|null always null. */ public static function execute_returns(): ?external_description { return null; } } PK9B\C(X2classes/external/create_grade_item_per_section.phpnu[. namespace mod_quiz\external; use coding_exception; use core_external\external_api; use core_external\external_description; use core_external\external_function_parameters; use core_external\external_multiple_structure; use core_external\external_single_structure; use core_external\external_value; use mod_quiz\quiz_attempt; use mod_quiz\quiz_settings; use moodle_exception; use stdClass; /** * For a quiz with no grade items yet, create a grade item for each section. * * And, assign the questions in each section to the corresponding grade item. * * The user must have the 'mod/quiz:manage' capability for the quiz. * * @package mod_quiz * @copyright 2024 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class create_grade_item_per_section extends external_api { /** * Declare the method parameters. * * @return external_function_parameters */ public static function execute_parameters(): external_function_parameters { return new external_function_parameters([ 'quizid' => new external_value(PARAM_INT, 'The quiz to update slots for.'), ]); } /** * For a quiz with no grade items yet, create a grade item for each section. * * And, assign the questions in each section to the corresponding grade item. * * The user must have the 'mod/quiz:manage' capability for the quiz. * * @param int $quizid the id of the quiz to setup grade items for. */ public static function execute(int $quizid): void { global $DB; [ 'quizid' => $quizid, ] = self::validate_parameters(self::execute_parameters(), [ 'quizid' => $quizid, ]); // Check the request is valid. $quizobj = quiz_settings::create($quizid); require_capability('mod/quiz:manage', $quizobj->get_context()); self::validate_context($quizobj->get_context()); $structure = $quizobj->get_structure(); if ($structure->get_grade_items()) { throw new coding_exception('Cannot use create_grade_item_per_section for a quiz ' . 'that already has grade items.'); } $transaction = $DB->start_delegated_transaction(); $gradeitemsids = []; foreach ($structure->get_sections() as $section) { // Only create a grade item for sections that contain at least one real question (not description). $hasrealquestion = false; foreach ($structure->get_slots_in_section($section->id) as $slot) { $hasrealquestion = $hasrealquestion || $structure->is_real_question($slot); } if (!$hasrealquestion) { continue; } // Grade item required. Create it. $gradeitem = new stdClass(); $gradeitem->quizid = $quizid; $gradeitem->name = $section->heading; $structure->create_grade_item($gradeitem); $gradeitemsids[$section->id] = $gradeitem->id; } foreach ($structure->get_slots() as $slot) { if ($structure->is_real_question($slot->slot)) { $structure->update_slot_grade_item($slot, $gradeitemsids[$slot->section->id]); } } $transaction->allow_commit(); } /** * Define the webservice response. * * @return external_description|null always null. */ public static function execute_returns(): ?external_description { return null; } } PK9B\Hs"classes/external/get_overrides.phpnu[. namespace mod_quiz\external; use core_external\external_api; use core_external\external_function_parameters; use core_external\external_multiple_structure; use core_external\external_single_structure; use core_external\external_value; use mod_quiz\quiz_settings; /** * Webservice for searching overrides. * * @package mod_quiz * @copyright 2024 Matthew Hilton * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class get_overrides extends external_api { /** * Defines parameters for getting quiz overrides. * * @return external_function_parameters */ public static function execute_parameters(): external_function_parameters { return new external_function_parameters([ 'quizid' => new external_value(PARAM_INT, 'ID of quiz to get overrides for'), ]); } /** * Executes webservice function, returning quiz overrides. * * @param int $quizid * @return array with overrides key which contains the overrides for the given quiz. */ public static function execute($quizid): array { $params = self::validate_parameters(self::execute_parameters(), ['quizid' => $quizid]); $quizsettings = quiz_settings::create($params['quizid']); $manager = $quizsettings->get_override_manager(); self::validate_context($manager->context); $manager->require_read_capability(); // Filter for those overrides user can access. $overrides = array_filter( $manager->get_all_overrides(), fn(\stdClass $override) => $manager->can_view_override( $override, $quizsettings->get_course(), $quizsettings->get_cm(), ), ); return ['overrides' => $overrides]; } /** * Defines return type * * @return external_single_structure */ public static function execute_returns(): external_single_structure { $overridedatastructure = new external_single_structure([ 'id' => new external_value(PARAM_INT, 'Override ID'), 'quiz' => new external_value(PARAM_INT, 'Quiz ID'), 'userid' => new external_value(PARAM_INT, 'User ID', VALUE_DEFAULT, null), 'groupid' => new external_value(PARAM_INT, 'Group ID', VALUE_DEFAULT, null), 'timeopen' => new external_value(PARAM_INT, 'Override time open value', VALUE_DEFAULT, null), 'timeclose' => new external_value(PARAM_INT, 'Override time close value', VALUE_DEFAULT, null), 'timelimit' => new external_value(PARAM_INT, 'Override time limit value', VALUE_DEFAULT, null), 'attempts' => new external_value(PARAM_INT, 'Override attempts value', VALUE_DEFAULT, null), 'password' => new external_value(PARAM_TEXT, 'Override password', VALUE_DEFAULT, null), ]); return new external_single_structure([ 'overrides' => new external_multiple_structure($overridedatastructure), ]); } } PK9B\kI  #classes/external/reopen_attempt.phpnu[. namespace mod_quiz\external; use core_external\external_api; use core_external\external_description; use core_external\external_function_parameters; use core_external\external_value; use mod_quiz\quiz_attempt; use moodle_exception; /** * Web service method for re-opening a quiz attempt. * * The use must have the 'mod/quiz:reopenattempts' capability and the attempt * must (at least for now) be in the 'Never submitted' state (quiz_attempt::ABANDONED). * * @package mod_quiz * @copyright 2023 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class reopen_attempt extends external_api { /** * Declare the method parameters. * * @return external_function_parameters */ public static function execute_parameters(): external_function_parameters { return new external_function_parameters([ 'attemptid' => new external_value(PARAM_INT, 'The id of the attempt to reopen'), ]); } /** * Re-opening a submitted attempt method implementation. * * @param int $attemptid the id of the attempt to reopen. */ public static function execute(int $attemptid): void { ['attemptid' => $attemptid] = self::validate_parameters( self::execute_parameters(), ['attemptid' => $attemptid]); // Check the request is valid. $attemptobj = quiz_attempt::create($attemptid); require_capability('mod/quiz:reopenattempts', $attemptobj->get_context()); self::validate_context($attemptobj->get_context()); if ($attemptobj->get_state() != quiz_attempt::ABANDONED) { throw new moodle_exception('reopenattemptwrongstate', 'quiz', '', ['attemptid' => $attemptid, 'state' => quiz_attempt_state_name($attemptobj->get_state())]); } // Re-open the attempt. $attemptobj->process_reopen_abandoned(time()); } /** * Define the webservice response. * * @return external_description|null always null. */ public static function execute_returns(): ?external_description { return null; } } PK9B\M& 'classes/external/create_grade_items.phpnu[. namespace mod_quiz\external; use core_external\external_api; use core_external\external_description; use core_external\external_function_parameters; use core_external\external_multiple_structure; use core_external\external_single_structure; use core_external\external_value; use mod_quiz\quiz_attempt; use mod_quiz\quiz_settings; use moodle_exception; /** * Web service method to create quiz grade items for a quiz. * * The user must have the 'mod/quiz:manage' capability for the quiz. * * The new grade items are added in order, after all the existing ones. * * @package mod_quiz * @copyright 2023 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class create_grade_items extends external_api { /** * Declare the method parameters. * * @return external_function_parameters */ public static function execute_parameters(): external_function_parameters { return new external_function_parameters([ 'quizid' => new external_value(PARAM_INT, 'The quiz to update slots for.'), 'quizgradeitems' => new external_multiple_structure( new external_single_structure([ 'name' => new external_value(PARAM_TEXT, 'The name for the grade item to create. If empty string, a sensible default is used.'), ]) ), ]); } /** * Add new grade items to this quiz. * * New items are added in order, at the end of the sort order. * * @param int $quizid the id of the quiz to add the grade items to. * @param array $gradeitems list of grade items to create. (Each one must have property name.) */ public static function execute(int $quizid, array $gradeitems): void { global $DB; [ 'quizid' => $quizid, 'quizgradeitems' => $gradeitems, ] = self::validate_parameters(self::execute_parameters(), [ 'quizid' => $quizid, 'quizgradeitems' => $gradeitems, ]); // Check the request is valid. $quizobj = quiz_settings::create($quizid); require_capability('mod/quiz:manage', $quizobj->get_context()); self::validate_context($quizobj->get_context()); $transaction = $DB->start_delegated_transaction(); $structure = $quizobj->get_structure(); foreach ($gradeitems as $gradeitemdata) { $gradeitem = (object) $gradeitemdata; $gradeitem->quizid = $quizid; $structure->create_grade_item($gradeitem); } $transaction->allow_commit(); } /** * Define the webservice response. * * @return external_description|null always null. */ public static function execute_returns(): ?external_description { return null; } } PK9B\( ,classes/external/submit_question_version.phpnu[. namespace mod_quiz\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\context\module; use core_external\external_api; use core_external\external_function_parameters; use core_external\external_single_structure; use core_external\external_value; use mod_quiz\quiz_settings; /** * External api for changing the question version in the quiz. * * @package mod_quiz * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class submit_question_version extends external_api { /** * Parameters for the submit_question_version. * * @return external_function_parameters */ public static function execute_parameters(): external_function_parameters { return new external_function_parameters([ 'slotid' => new external_value(PARAM_INT), 'newversion' => new external_value(PARAM_INT), ]); } /** * Set the questions slot parameters to display the question template. * * @param int $slotid Slot ID to display. * @param int|null $newversion The version to set. Passing null means 'always latest'. * For historical reasons, 0 also means 'always latest'. * @return array */ public static function execute(int $slotid, ?int $newversion): array { global $DB; $params = self::validate_parameters(self::execute_parameters(), ['slotid' => $slotid, 'newversion' => $newversion]); $slot = $DB->get_record('quiz_slots', ['id' => $slotid], '*', MUST_EXIST); $context = module::instance(get_course_and_cm_from_instance($slot->quizid, 'quiz')[1]->id); self::validate_context($context); require_capability('mod/quiz:manage', $context); $quizobj = quiz_settings::create($slot->quizid); // This WS historically (and wrongly) accepted 0 to mean 'always latest'. The correct behaviour // is that null implies awlays latest. To preserve backwards compatibility, we continue to accept // 0, but just turn it in to null before passing to the appropriate API. See: MDL-82587. $newversionnormalised = $params['newversion'] === 0 ? null : $params['newversion']; return ['result' => $quizobj->get_structure()->update_slot_version($slot->id, $newversionnormalised)]; } /** * Define the webservice response. * * @return \core_external\external_description */ public static function execute_returns() { return new external_single_structure([ 'result' => new external_value(PARAM_BOOL, '') ]); } } PK9B\. namespace mod_quiz\external; use core_external\external_api; use core_external\external_description; use core_external\external_function_parameters; use core_external\external_multiple_structure; use core_external\external_single_structure; use core_external\external_value; use mod_quiz\quiz_attempt; use mod_quiz\quiz_settings; use moodle_exception; /** * Web service method to update the properties of quiz grade items. * * The user must have the 'mod/quiz:manage' capability for the quiz. * * All the properties that can be set are optional. Only the ones passed are changed. * * @package mod_quiz * @copyright 2023 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class update_grade_items extends external_api { /** * Declare the method parameters. * * @return external_function_parameters */ public static function execute_parameters(): external_function_parameters { return new external_function_parameters([ 'quizid' => new external_value(PARAM_INT, 'The quiz to update slots for.'), 'quizgradeitems' => new external_multiple_structure( new external_single_structure([ 'id' => new external_value(PARAM_INT, 'id of the quiz grade item'), 'name' => new external_value( PARAM_TEXT, 'If passed, new name to set. Null, or not specified, to leave unchanged.', VALUE_OPTIONAL ), ]) ), ]); } /** * Update grade items to this quiz. * * @param int $quizid the id of the quiz from which to dlete grade items. * @param array $gradeitems list of grade items to update. Must have properties id and name */ public static function execute(int $quizid, array $gradeitems): void { global $DB; [ 'quizid' => $quizid, 'quizgradeitems' => $gradeitems, ] = self::validate_parameters(self::execute_parameters(), [ 'quizid' => $quizid, 'quizgradeitems' => $gradeitems, ]); // Check the request is valid. $quizobj = quiz_settings::create($quizid); require_capability('mod/quiz:manage', $quizobj->get_context()); self::validate_context($quizobj->get_context()); $transaction = $DB->start_delegated_transaction(); $structure = $quizobj->get_structure(); foreach ($gradeitems as $gradeitemdata) { if ($gradeitemdata['name'] !== null) { $structure->update_grade_item((object) $gradeitemdata); } } $transaction->allow_commit(); } /** * Define the webservice response. * * @return external_description|null always null. */ public static function execute_returns(): ?external_description { return null; } } PK9B\Ft t %classes/external/delete_overrides.phpnu[. namespace mod_quiz\external; use core_external\external_api; use core_external\external_function_parameters; use core_external\external_multiple_structure; use core_external\external_single_structure; use core_external\external_value; use mod_quiz\quiz_settings; /** * Webservice for deleting quiz overrides. * * @package mod_quiz * @copyright 2024 Matthew Hilton * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class delete_overrides extends external_api { /** * Defines parameters * * @return external_function_parameters */ public static function execute_parameters(): external_function_parameters { return new external_function_parameters([ // This must be nested in a single structure, because the ids structure does not play nicely at the top level. 'data' => new external_single_structure([ 'quizid' => new external_value(PARAM_INT, "ID of quiz to delete overrides in"), 'ids' => new external_multiple_structure(new external_value(PARAM_INT, 'ID of override to delete')), ]), ]); } /** * Executes webservice function, deleting given overrides. * * @param array $params array of override parameters * @return array with ids key, which contains the ids of the overrides successfully deleted. */ public static function execute($params): array { $params = self::validate_parameters(self::execute_parameters(), ['data' => $params])['data']; $quizsettings = quiz_settings::create($params['quizid']); $manager = $quizsettings->get_override_manager(); self::validate_context($manager->context); $manager->require_manage_capability(); $manager->delete_overrides_by_id($params['ids']); return ['ids' => $params['ids']]; } /** * Defines return type * * @return external_single_structure */ public static function execute_returns(): external_single_structure { return new external_single_structure([ 'ids' => new external_multiple_structure(new external_value(PARAM_INT, 'ID of deleted override')), ]); } } PK9B\n,classes/external/update_filter_condition.phpnu[. namespace mod_quiz\external; defined('MOODLE_INTERNAL') || die(); require_once($CFG->libdir . '/externallib.php'); require_once($CFG->dirroot . '/question/editlib.php'); require_once($CFG->dirroot . '/mod/quiz/locallib.php'); use external_function_parameters; use external_single_structure; use external_value; use external_api; use stdClass; /** * Update the filter condition for a random question. * * @package mod_quiz * @copyright 2022 Catalyst IT Australia Pty Ltd * @author Nathan Nguyen * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class update_filter_condition extends external_api { /** * Parameters for the web service function * * @return external_function_parameters */ public static function execute_parameters(): external_function_parameters { return new external_function_parameters ([ 'cmid' => new external_value(PARAM_INT, 'The cmid of the quiz'), 'slotid' => new external_value(PARAM_INT, 'The quiz slot ID for the random question.'), 'filtercondition' => new external_value(PARAM_TEXT, 'Filter condition'), ]); } /** * Add random questions. * * @param int $cmid course module id * @param int $slotid The quiz slot id * @param string $filtercondition * @return array result */ public static function execute( int $cmid, int $slotid, string $filtercondition, ): array { global $DB; [ 'cmid' => $cmid, 'slotid' => $slotid, 'filtercondition' => $filtercondition, ] = self::validate_parameters(self::execute_parameters(), [ 'cmid' => $cmid, 'slotid' => $slotid, 'filtercondition' => $filtercondition, ]); // Validate context. $thiscontext = \context_module::instance($cmid); self::validate_context($thiscontext); require_capability('mod/quiz:manage', $thiscontext); // Validate question category is supplied in filter. $decodedfiltercondition = json_decode($filtercondition, true); $categoryid = (isset($decodedfiltercondition['filter']['category']['values'][0])) ? $decodedfiltercondition['filter']['category']['values'][0] : false; if (!$categoryid) { throw new \moodle_exception('invalidcategoryid'); } // Validate question category exists. $categorycontextid = $DB->get_field('question_categories', 'contextid', ['id' => $categoryid], MUST_EXIST); // Validate set_reference record exists for this quiz and slot. $setreferenceid = $DB->get_field('question_set_references', 'id', [ 'itemid' => $slotid, 'questionarea' => 'slot', 'component' => 'mod_quiz', ], MUST_EXIST); // Update set_reference record with new filtercondition and context. $update = new stdClass(); $update->id = $setreferenceid; $update->filtercondition = $filtercondition; $update->questionscontextid = $categorycontextid; $DB->update_record('question_set_references', $update); return ['message' => get_string('updatefilterconditon_success', 'mod_quiz')]; } /** * Returns description of method result value. * * @return external_value */ public static function execute_returns() { return new external_single_structure([ 'message' => new external_value(PARAM_TEXT, 'Message', VALUE_OPTIONAL) ]); } } PK9B\W+h h /classes/external/get_edit_grading_page_data.phpnu[. namespace mod_quiz\external; use core_external\external_api; use core_external\external_description; use core_external\external_function_parameters; use core_external\external_value; use Exception; use html_writer; use mod_quiz\output\edit_grading_page; use mod_quiz\quiz_attempt; use mod_quiz\quiz_settings; use moodle_exception; /** * Web service to get the data required o re-render the Quiz grading setup page. * * The use must have the 'mod/quiz:manage' capability. * * @package mod_quiz * @copyright 2023 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class get_edit_grading_page_data extends external_api { /** * Declare the method parameters. * * @return external_function_parameters */ public static function execute_parameters(): external_function_parameters { return new external_function_parameters([ 'quizid' => new external_value(PARAM_INT, 'The quiz for which to return the data.'), ]); } /** * Check a quiz attempt state, and return a confirmation message method implementation. * * @param int $quizid the quiz for which to return the data. * @return string a suitable confirmation message (HTML), if the attempt is suitable to be reopened. * @throws Exception an appropriate exception if the attempt cannot be reopened now. */ public static function execute(int $quizid): string { global $PAGE; [ 'quizid' => $quizid, ] = self::validate_parameters(self::execute_parameters(), [ 'quizid' => $quizid, ]); // Check the request is valid. $quizobj = quiz_settings::create($quizid); require_capability('mod/quiz:manage', $quizobj->get_context()); self::validate_context($quizobj->get_context()); // Set dummy URL to stop debugging in the renderer (TODO: remove as part of MDL-76728). $PAGE->set_url('/'); $structure = $quizobj->get_structure(); $editpage = new edit_grading_page($structure); return json_encode($editpage->export_for_template($PAGE->get_renderer('core'))); } /** * Define the webservice response. * * @return external_description */ public static function execute_returns(): external_description { return new external_value(PARAM_RAW, 'JSON-encoded data required to render the mod_quiz/edit_grading_page template.'); } } PK9B\^v#classes/external/save_overrides.phpnu[. namespace mod_quiz\external; use core_external\external_api; use core_external\external_function_parameters; use core_external\external_multiple_structure; use core_external\external_single_structure; use core_external\external_value; use mod_quiz\quiz_settings; /** * Webservice for upserting quiz overrides. * * @package mod_quiz * @copyright 2024 Matthew Hilton * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class save_overrides extends external_api { /** * Defines parameters * * @return external_function_parameters */ public static function execute_parameters(): external_function_parameters { $overridestructure = new external_single_structure([ 'id' => new external_value(PARAM_INT, 'ID of existing override (if updating)', VALUE_DEFAULT, null), 'groupid' => new external_value(PARAM_INT, 'ID of group', VALUE_DEFAULT, null), 'userid' => new external_value(PARAM_INT, 'ID of user', VALUE_DEFAULT, null), 'timeopen' => new external_value(PARAM_INT, 'Quiz override opening timestamp', VALUE_DEFAULT, null), 'timeclose' => new external_value(PARAM_INT, 'Quiz override closing timestamp', VALUE_OPTIONAL, null), 'timelimit' => new external_value(PARAM_INT, 'Quiz override time limit', VALUE_DEFAULT, null), 'attempts' => new external_value(PARAM_INT, 'Quiz override attempt count', VALUE_DEFAULT, null), 'password' => new external_value(PARAM_TEXT, 'Quiz override password', VALUE_DEFAULT, null), ]); return new external_function_parameters([ // This must be nested in a single structure, because the overrides structure does not play nicely at the top level. 'data' => new external_single_structure([ 'quizid' => new external_value(PARAM_INT, 'ID of quiz to save overrides to'), 'overrides' => new external_multiple_structure($overridestructure), ]), ]); } /** * Executes webservice function, saving the requested overrides. * * @param array $data array with quizid key and overrides key containing list of overrides to save. * @return array with ids key which contains ids of created/updated overrides. */ public static function execute($data): array { $params = self::validate_parameters(self::execute_parameters(), ['data' => $data])['data']; $quizsettings = quiz_settings::create($params['quizid']); $manager = $quizsettings->get_override_manager(); self::validate_context($manager->context); $manager->require_manage_capability(); // Filter for those overrides user can access. $overrides = array_filter( $params['overrides'], fn(array $override) => $manager->can_view_override( (object) $override, $quizsettings->get_course(), $quizsettings->get_cm(), ), ); // Iterate over and save all overrides. $ids = array_map(fn($override) => $manager->save_override($override), $overrides); return ['ids' => $ids]; } /** * Defines return type * * @return external_single_structure */ public static function execute_returns(): external_single_structure { return new external_single_structure([ 'ids' => new external_multiple_structure(new external_value(PARAM_INT, 'ID of created/updated override')), ]); } } PK9B\6classes/privacy/helper.phpnu[. /** * Privacy Subsystem helper for mod_quiz. * * @package mod_quiz * @copyright 2018 Andrew Nicols * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace mod_quiz\privacy; use \core_privacy\local\request\writer; use \core_privacy\local\request\transform; use \core_privacy\local\request\contextlist; use \core_privacy\local\request\approved_contextlist; use \core_privacy\local\request\deletion_criteria; use \core_privacy\local\metadata\collection; use \core_privacy\manager; defined('MOODLE_INTERNAL') || die(); require_once($CFG->dirroot . '/mod/quiz/lib.php'); require_once($CFG->dirroot . '/mod/quiz/locallib.php'); /** * Privacy Subsystem implementation for mod_quiz. * * @copyright 2018 Andrew Nicols * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class helper { /** * Determine the subcontext for the specified quiz attempt. * * @param \stdClass $attempt The attempt data retrieved from the database. * @param \stdClass $user The user record. * @return \array The calculated subcontext. */ public static function get_quiz_attempt_subcontext(\stdClass $attempt, \stdClass $user) { $subcontext = [ get_string('attempts', 'mod_quiz'), ]; if ($attempt->userid != $user->id) { $subcontext[] = fullname($user); } $subcontext[] = $attempt->attempt; return $subcontext; } } PK9B\J&&,classes/privacy/quizaccess_user_provider.phpnu[. /** * The quizaccess_user_provider interface provides the expected interface for all 'quizaccess' quizaccesss. * * Quiz sub plugins should implement this if they store personal information and can retrieve a userid. * * @package mod_quiz * @copyright 2018 Shamim Rezaie * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace mod_quiz\privacy; defined('MOODLE_INTERNAL') || die(); use core_privacy\local\request\approved_userlist; interface quizaccess_user_provider extends \core_privacy\local\request\plugin\subplugin_provider { /** * Delete multiple users data within a single context. * * @param approved_userlist $userlist The approved context and user information to delete information for. */ public static function delete_quizaccess_data_for_users(approved_userlist $userlist); } PK9B\AMeggclasses/privacy/provider.phpnu[. /** * Privacy Subsystem implementation for mod_quiz. * * @package mod_quiz * @category privacy * @copyright 2018 Andrew Nicols * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace mod_quiz\privacy; 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\deletion_criteria; use core_privacy\local\request\transform; use core_privacy\local\metadata\collection; use core_privacy\local\request\userlist; use core_privacy\local\request\writer; use core_privacy\manager; use mod_quiz\quiz_attempt; defined('MOODLE_INTERNAL') || die(); require_once($CFG->dirroot . '/mod/quiz/lib.php'); require_once($CFG->dirroot . '/mod/quiz/locallib.php'); /** * Privacy Subsystem implementation for mod_quiz. * * @copyright 2018 Andrew Nicols * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class provider implements // This plugin has data. \core_privacy\local\metadata\provider, // This plugin currently implements the original plugin_provider interface. \core_privacy\local\request\plugin\provider, // This plugin is capable of determining which users have data within it. \core_privacy\local\request\core_userlist_provider { /** * Get the list of contexts that contain user information for the specified user. * * @param collection $items The collection to add metadata to. * @return collection The array of metadata */ public static function get_metadata(collection $items): collection { // The table 'quiz' stores a record for each quiz. // It does not contain user personal data, but data is returned from it for contextual requirements. // The table 'quiz_attempts' stores a record of each quiz attempt. // It contains a userid which links to the user making the attempt and contains information about that attempt. $items->add_database_table('quiz_attempts', [ 'attempt' => 'privacy:metadata:quiz_attempts:attempt', 'currentpage' => 'privacy:metadata:quiz_attempts:currentpage', 'preview' => 'privacy:metadata:quiz_attempts:preview', 'state' => 'privacy:metadata:quiz_attempts:state', 'timestart' => 'privacy:metadata:quiz_attempts:timestart', 'timefinish' => 'privacy:metadata:quiz_attempts:timefinish', 'timemodified' => 'privacy:metadata:quiz_attempts:timemodified', 'timemodifiedoffline' => 'privacy:metadata:quiz_attempts:timemodifiedoffline', 'timecheckstate' => 'privacy:metadata:quiz_attempts:timecheckstate', 'sumgrades' => 'privacy:metadata:quiz_attempts:sumgrades', 'gradednotificationsenttime' => 'privacy:metadata:quiz_attempts:gradednotificationsenttime', ], 'privacy:metadata:quiz_attempts'); // The table 'quiz_feedback' contains the feedback responses which will be shown to users depending upon the // grade they achieve in the quiz. // It does not identify the user who wrote the feedback item so cannot be returned directly and is not // described, but relevant feedback items will be included with the quiz export for a user who has a grade. // The table 'quiz_grades' contains the current grade for each quiz/user combination. $items->add_database_table('quiz_grades', [ 'quiz' => 'privacy:metadata:quiz_grades:quiz', 'userid' => 'privacy:metadata:quiz_grades:userid', 'grade' => 'privacy:metadata:quiz_grades:grade', 'timemodified' => 'privacy:metadata:quiz_grades:timemodified', ], 'privacy:metadata:quiz_grades'); // The table 'quiz_overrides' contains any user or group overrides for users. // It should be included where data exists for a user. $items->add_database_table('quiz_overrides', [ 'quiz' => 'privacy:metadata:quiz_overrides:quiz', 'userid' => 'privacy:metadata:quiz_overrides:userid', 'timeopen' => 'privacy:metadata:quiz_overrides:timeopen', 'timeclose' => 'privacy:metadata:quiz_overrides:timeclose', 'timelimit' => 'privacy:metadata:quiz_overrides:timelimit', ], 'privacy:metadata:quiz_overrides'); // These define the structure of the quiz. // The table 'quiz_sections' contains data about the structure of a quiz. // It does not contain any user identifying data and does not need a mapping. // The table 'quiz_slots' contains data about the structure of a quiz. // It does not contain any user identifying data and does not need a mapping. // The table 'quiz_reports' does not contain any user identifying data and does not need a mapping. // The table 'quiz_statistics' contains abstract statistics about question usage and cannot be mapped to any // specific user. // It does not contain any user identifying data and does not need a mapping. // The quiz links to the 'core_question' subsystem for all question functionality. $items->add_subsystem_link('core_question', [], 'privacy:metadata:core_question'); // The quiz has two subplugins.. $items->add_plugintype_link('quiz', [], 'privacy:metadata:quiz'); $items->add_plugintype_link('quizaccess', [], 'privacy:metadata:quizaccess'); // Although the quiz supports the core_completion API and defines custom completion items, these will be // noted by the manager as all activity modules are capable of supporting this functionality. return $items; } /** * Get the list of contexts where the specified user has attempted a quiz, or been involved with manual marking * and/or grading of a quiz. * * @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 { $resultset = new contextlist(); // Users who attempted the quiz. $sql = "SELECT c.id FROM {context} c JOIN {course_modules} cm ON cm.id = c.instanceid AND c.contextlevel = :contextlevel JOIN {modules} m ON m.id = cm.module AND m.name = :modname JOIN {quiz} q ON q.id = cm.instance JOIN {quiz_attempts} qa ON qa.quiz = q.id WHERE qa.userid = :userid AND qa.preview = 0"; $params = ['contextlevel' => CONTEXT_MODULE, 'modname' => 'quiz', 'userid' => $userid]; $resultset->add_from_sql($sql, $params); // Users with quiz overrides. $sql = "SELECT c.id FROM {context} c JOIN {course_modules} cm ON cm.id = c.instanceid AND c.contextlevel = :contextlevel JOIN {modules} m ON m.id = cm.module AND m.name = :modname JOIN {quiz} q ON q.id = cm.instance JOIN {quiz_overrides} qo ON qo.quiz = q.id WHERE qo.userid = :userid"; $params = ['contextlevel' => CONTEXT_MODULE, 'modname' => 'quiz', 'userid' => $userid]; $resultset->add_from_sql($sql, $params); // Get the SQL used to link indirect question usages for the user. // This includes where a user is the manual marker on a question attempt. $qubaid = \core_question\privacy\provider::get_related_question_usages_for_user('rel', 'mod_quiz', 'qa.uniqueid', $userid); // Select the context of any quiz attempt where a user has an attempt, plus the related usages. $sql = "SELECT c.id FROM {context} c JOIN {course_modules} cm ON cm.id = c.instanceid AND c.contextlevel = :contextlevel JOIN {modules} m ON m.id = cm.module AND m.name = :modname JOIN {quiz} q ON q.id = cm.instance JOIN {quiz_attempts} qa ON qa.quiz = q.id " . $qubaid->from . " WHERE " . $qubaid->where() . " AND qa.preview = 0"; $params = ['contextlevel' => CONTEXT_MODULE, 'modname' => 'quiz'] + $qubaid->from_where_params(); $resultset->add_from_sql($sql, $params); return $resultset; } /** * 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(); if (!$context instanceof \context_module) { return; } $params = [ 'cmid' => $context->instanceid, 'modname' => 'quiz', ]; // Users who attempted the quiz. $sql = "SELECT qa.userid FROM {course_modules} cm JOIN {modules} m ON m.id = cm.module AND m.name = :modname JOIN {quiz} q ON q.id = cm.instance JOIN {quiz_attempts} qa ON qa.quiz = q.id WHERE cm.id = :cmid AND qa.preview = 0"; $userlist->add_from_sql('userid', $sql, $params); // Users with quiz overrides. $sql = "SELECT qo.userid FROM {course_modules} cm JOIN {modules} m ON m.id = cm.module AND m.name = :modname JOIN {quiz} q ON q.id = cm.instance JOIN {quiz_overrides} qo ON qo.quiz = q.id WHERE cm.id = :cmid"; $userlist->add_from_sql('userid', $sql, $params); // Question usages in context. // This includes where a user is the manual marker on a question attempt. $sql = "SELECT qa.uniqueid FROM {course_modules} cm JOIN {modules} m ON m.id = cm.module AND m.name = :modname JOIN {quiz} q ON q.id = cm.instance JOIN {quiz_attempts} qa ON qa.quiz = q.id WHERE cm.id = :cmid AND qa.preview = 0"; \core_question\privacy\provider::get_users_in_context_from_sql($userlist, 'qn', $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 $DB; if (!count($contextlist)) { return; } $user = $contextlist->get_user(); $userid = $user->id; list($contextsql, $contextparams) = $DB->get_in_or_equal($contextlist->get_contextids(), SQL_PARAMS_NAMED); $sql = "SELECT q.*, qg.id AS hasgrade, qg.grade AS bestgrade, qg.timemodified AS grademodified, qo.id AS hasoverride, qo.timeopen AS override_timeopen, qo.timeclose AS override_timeclose, qo.timelimit AS override_timelimit, c.id AS contextid, cm.id AS cmid FROM {context} c INNER JOIN {course_modules} cm ON cm.id = c.instanceid AND c.contextlevel = :contextlevel INNER JOIN {modules} m ON m.id = cm.module AND m.name = :modname INNER JOIN {quiz} q ON q.id = cm.instance LEFT JOIN {quiz_overrides} qo ON qo.quiz = q.id AND qo.userid = :qouserid LEFT JOIN {quiz_grades} qg ON qg.quiz = q.id AND qg.userid = :qguserid WHERE c.id {$contextsql}"; $params = [ 'contextlevel' => CONTEXT_MODULE, 'modname' => 'quiz', 'qguserid' => $userid, 'qouserid' => $userid, ]; $params += $contextparams; // Fetch the individual quizzes. $quizzes = $DB->get_recordset_sql($sql, $params); foreach ($quizzes as $quiz) { list($course, $cm) = get_course_and_cm_from_cmid($quiz->cmid, 'quiz'); $quizobj = new \mod_quiz\quiz_settings($quiz, $cm, $course); $context = $quizobj->get_context(); $quizdata = \core_privacy\local\request\helper::get_context_data($context, $contextlist->get_user()); \core_privacy\local\request\helper::export_context_files($context, $contextlist->get_user()); if (!empty($quizdata->timeopen)) { $quizdata->timeopen = transform::datetime($quiz->timeopen); } if (!empty($quizdata->timeclose)) { $quizdata->timeclose = transform::datetime($quiz->timeclose); } if (!empty($quizdata->timelimit)) { $quizdata->timelimit = $quiz->timelimit; } if (!empty($quiz->hasoverride)) { $quizdata->override = (object) []; if (!empty($quizdata->override_override_timeopen)) { $quizdata->override->timeopen = transform::datetime($quiz->override_timeopen); } if (!empty($quizdata->override_timeclose)) { $quizdata->override->timeclose = transform::datetime($quiz->override_timeclose); } if (!empty($quizdata->override_timelimit)) { $quizdata->override->timelimit = $quiz->override_timelimit; } } $quizdata->accessdata = (object) []; $components = \core_component::get_plugin_list('quizaccess'); $exportparams = [ $quizobj, $user, ]; foreach (array_keys($components) as $component) { $classname = manager::get_provider_classname_for_component("quizaccess_$component"); if (class_exists($classname) && is_subclass_of($classname, quizaccess_provider::class)) { $result = component_class_callback($classname, 'export_quizaccess_user_data', $exportparams); if (count((array) $result)) { $quizdata->accessdata->$component = $result; } } } if (empty((array) $quizdata->accessdata)) { unset($quizdata->accessdata); } writer::with_context($context) ->export_data([], $quizdata); } $quizzes->close(); // Store all quiz attempt data. static::export_quiz_attempts($contextlist); } /** * Delete all data for all users in the specified context. * * @param context $context The specific context to delete data for. */ public static function delete_data_for_all_users_in_context(\context $context) { if ($context->contextlevel != CONTEXT_MODULE) { // Only quiz module will be handled. return; } $cm = get_coursemodule_from_id('quiz', $context->instanceid); if (!$cm) { // Only quiz module will be handled. return; } $quizobj = \mod_quiz\quiz_settings::create($cm->instance); $quiz = $quizobj->get_quiz(); // Handle the 'quizaccess' subplugin. manager::plugintype_class_callback( 'quizaccess', quizaccess_provider::class, 'delete_subplugin_data_for_all_users_in_context', [$quizobj] ); // Delete all overrides. $quizobj->get_override_manager()->delete_all_overrides(shouldlog: false); // This will delete all question attempts, quiz attempts, and quiz grades for this quiz. quiz_delete_all_attempts($quiz); } /** * 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; foreach ($contextlist as $context) { if ($context->contextlevel != CONTEXT_MODULE) { // Only quiz module will be handled. continue; } $cm = get_coursemodule_from_id('quiz', $context->instanceid); if (!$cm) { // Only quiz module will be handled. continue; } // Fetch the details of the data to be removed. $quizobj = \mod_quiz\quiz_settings::create($cm->instance); $quiz = $quizobj->get_quiz(); $user = $contextlist->get_user(); // Handle the 'quizaccess' quizaccess. manager::plugintype_class_callback( 'quizaccess', quizaccess_provider::class, 'delete_quizaccess_data_for_user', [$quizobj, $user] ); // Remove overrides for this user. $overrides = $DB->get_records('quiz_overrides' , [ 'quiz' => $quizobj->get_quizid(), 'userid' => $user->id, ]); $manager = $quizobj->get_override_manager(); $manager->delete_overrides( overrides: $overrides, shouldlog: false, ); // This will delete all question attempts, quiz attempts, and quiz grades for this quiz. quiz_delete_user_attempts($quizobj, $user); } } /** * 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; $context = $userlist->get_context(); if ($context->contextlevel != CONTEXT_MODULE) { // Only quiz module will be handled. return; } $cm = get_coursemodule_from_id('quiz', $context->instanceid); if (!$cm) { // Only quiz module will be handled. return; } $quizobj = \mod_quiz\quiz_settings::create($cm->instance); $quiz = $quizobj->get_quiz(); $userids = $userlist->get_userids(); // Handle the 'quizaccess' quizaccess. manager::plugintype_class_callback( 'quizaccess', quizaccess_user_provider::class, 'delete_quizaccess_data_for_users', [$userlist] ); foreach ($userids as $userid) { // Remove overrides for this user. $overrides = $DB->get_records('quiz_overrides' , [ 'quiz' => $quizobj->get_quizid(), 'userid' => $userid, ]); $manager = $quizobj->get_override_manager(); $manager->delete_overrides( overrides: $overrides, shouldlog: false, ); // This will delete all question attempts, quiz attempts, and quiz grades for this user in the given quiz. quiz_delete_user_attempts($quizobj, (object)['id' => $userid]); } } /** * Store all quiz attempts for the contextlist. * * @param approved_contextlist $contextlist */ protected static function export_quiz_attempts(approved_contextlist $contextlist) { global $DB; $userid = $contextlist->get_user()->id; list($contextsql, $contextparams) = $DB->get_in_or_equal($contextlist->get_contextids(), SQL_PARAMS_NAMED); $qubaid1 = \core_question\privacy\provider::get_related_question_usages_for_user( 'rel1', 'mod_quiz', 'qa.uniqueid', $userid ); $qubaid2 = \core_question\privacy\provider::get_related_question_usages_for_user( 'rel2', 'mod_quiz', 'qa.uniqueid', $userid ); // Filtering the return values to be only those used to generate the data, this will have the benefit // improving performance on all databases as we will no longer be returning a text field for each row. $attemptfields = 'qa.id, qa.quiz, qa.userid, qa.attempt, qa.uniqueid, qa.preview, qa.state, qa.timestart, ' . 'qa.timefinish, qa.timemodified, qa.timemodifiedoffline, qa.timecheckstate, qa.sumgrades, ' . 'qa.gradednotificationsenttime'; $sql = "SELECT c.id AS contextid, cm.id AS cmid, $attemptfields FROM {context} c JOIN {course_modules} cm ON cm.id = c.instanceid AND c.contextlevel = :contextlevel1 JOIN {modules} m ON m.id = cm.module AND m.name = 'quiz' JOIN {quiz} q ON q.id = cm.instance JOIN {quiz_attempts} qa ON qa.quiz = q.id " . $qubaid1->from. " WHERE qa.userid = :qauserid AND qa.preview = 0 UNION SELECT c.id AS contextid, cm.id AS cmid, $attemptfields FROM {context} c JOIN {course_modules} cm ON cm.id = c.instanceid AND c.contextlevel = :contextlevel2 JOIN {modules} m ON m.id = cm.module AND m.name = 'quiz' JOIN {quiz} q ON q.id = cm.instance JOIN {quiz_attempts} qa ON qa.quiz = q.id " . $qubaid2->from. " WHERE " . $qubaid2->where() . " AND qa.preview = 0 "; $params = array_merge( [ 'contextlevel1' => CONTEXT_MODULE, 'contextlevel2' => CONTEXT_MODULE, 'qauserid' => $userid, ], $qubaid1->from_where_params(), $qubaid2->from_where_params(), ); $attempts = $DB->get_recordset_sql($sql, $params); foreach ($attempts as $attempt) { $quiz = $DB->get_record('quiz', ['id' => $attempt->quiz]); $context = \context_module::instance($attempt->cmid); $attemptsubcontext = helper::get_quiz_attempt_subcontext($attempt, $contextlist->get_user()); $options = quiz_get_review_options($quiz, $attempt, $context); if ($attempt->userid == $userid) { // This attempt was made by the user. // They 'own' all data on it. // Store the question usage data. \core_question\privacy\provider::export_question_usage($userid, $context, $attemptsubcontext, $attempt->uniqueid, $options, true ); // Store the quiz attempt data. $data = (object) [ 'state' => quiz_attempt::state_name($attempt->state), ]; if (!empty($attempt->timestart)) { $data->timestart = transform::datetime($attempt->timestart); } if (!empty($attempt->timefinish)) { $data->timefinish = transform::datetime($attempt->timefinish); } if (!empty($attempt->timemodified)) { $data->timemodified = transform::datetime($attempt->timemodified); } if (!empty($attempt->timemodifiedoffline)) { $data->timemodifiedoffline = transform::datetime($attempt->timemodifiedoffline); } if (!empty($attempt->timecheckstate)) { $data->timecheckstate = transform::datetime($attempt->timecheckstate); } if (!empty($attempt->gradednotificationsenttime)) { $data->gradednotificationsenttime = transform::datetime($attempt->gradednotificationsenttime); } if ($options->marks == \question_display_options::MARK_AND_MAX) { $grade = quiz_rescale_grade($attempt->sumgrades, $quiz, false); $data->grade = (object) [ 'grade' => quiz_format_grade($quiz, $grade), 'feedback' => quiz_feedback_for_grade($grade, $quiz, $context), ]; } writer::with_context($context) ->export_data($attemptsubcontext, $data); } else { // This attempt was made by another user. // The current user may have marked part of the quiz attempt. \core_question\privacy\provider::export_question_usage( $userid, $context, $attemptsubcontext, $attempt->uniqueid, $options, false ); } } $attempts->close(); } } PK9B\1fI .classes/privacy/legacy_quizaccess_polyfill.phpnu[. /** * This file contains the polyfil to allow a plugin to operate with Moodle 3.3 up. * * @package mod_quiz * @copyright 2018 Andrew Nicols * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace mod_quiz\privacy; use core_privacy\local\request\approved_userlist; defined('MOODLE_INTERNAL') || die(); /** * The trait used to provide a backwards compatibility for third-party plugins. * * @package mod_quiz * @copyright 2018 Andrew Nicols * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ trait legacy_quizaccess_polyfill { /** * Export all user data for the specified user, for the specified quiz. * * @param \mod_quiz\quiz_settings $quiz The quiz being exported * @param \stdClass $user The user to export data for * @return \stdClass The data to be exported for this access rule. */ public static function export_quizaccess_user_data(\mod_quiz\quiz_settings $quiz, \stdClass $user): \stdClass { return static::_export_quizaccess_user_data($quiz, $user); } /** * Delete all data for all users in the specified quiz. * * @param \mod_quiz\quiz_settings $quiz The quiz being deleted */ public static function delete_quizaccess_data_for_all_users_in_context(\mod_quiz\quiz_settings $quiz) { static::_delete_quizaccess_data_for_all_users_in_context($quiz); } /** * Delete all user data for the specified user, in the specified quiz. * * @param \mod_quiz\quiz_settings $quiz The quiz being deleted * @param \stdClass $user The user to export data for */ public static function delete_quizaccess_data_for_user(\mod_quiz\quiz_settings $quiz, \stdClass $user) { static::_delete_quizaccess_data_for_user($quiz, $user); } /** * Delete all user data for the specified users, in the specified context. * * @param approved_userlist $userlist The approved context and user information to delete information for. */ public static function delete_quizaccess_data_for_users(approved_userlist $userlist) { static::_delete_quizaccess_data_for_users($userlist); } } PK9B\ 'classes/privacy/quizaccess_provider.phpnu[. /** * The quizaccess_provider interface provides the expected interface for all 'quizaccess' quizaccesss. * * @package mod_quiz * @copyright 2018 Andrew Nicols * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace mod_quiz\privacy; defined('MOODLE_INTERNAL') || die(); use \core_privacy\local\request\contextlist; use \core_privacy\local\request\approved_contextlist; /** * The quizaccess_provider interface provides the expected interface for all 'quizaccess' quizaccesss. * * @package mod_quiz * @copyright 2018 Andrew Nicols * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ interface quizaccess_provider extends \core_privacy\local\request\plugin\subplugin_provider { /** * Export all user data for the specified user, for the specified quiz. * * @param \mod_quiz\quiz_settings $quiz The quiz being exported * @param \stdClass $user The user to export data for * @return \stdClass The data to be exported for this access rule. */ public static function export_quizaccess_user_data(\mod_quiz\quiz_settings $quiz, \stdClass $user): \stdClass; /** * Delete all data for all users in the specified quiz. * * @param \mod_quiz\quiz_settings $quiz The quiz being deleted */ public static function delete_quizaccess_data_for_all_users_in_context(\mod_quiz\quiz_settings $quiz); /** * Delete all user data for the specified user, in the specified quiz. * * @param \mod_quiz\quiz_settings $quiz The quiz being deleted * @param \stdClass $user The user to export data for */ public static function delete_quizaccess_data_for_user(\mod_quiz\quiz_settings $quiz, \stdClass $user); } PK9B\M?]]classes/dates.phpnu[. /** * Contains the class for fetching the important dates in mod_quiz for a given module instance and a user. * * @package mod_quiz * @copyright 2021 Shamim Rezaie * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ declare(strict_types=1); namespace mod_quiz; use core\activity_dates; /** * Class for fetching the important dates in mod_quiz for a given module instance and a user. * * @copyright 2021 Shamim Rezaie * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class dates extends activity_dates { /** * Returns a list of important dates in mod_quiz * * @return array */ protected function get_dates(): array { $timeopen = $this->cm->customdata['timeopen'] ?? null; $timeclose = $this->cm->customdata['timeclose'] ?? null; $now = time(); $dates = []; if ($timeopen) { $openlabelid = $timeopen > $now ? 'activitydate:opens' : 'activitydate:opened'; $dates[] = [ 'dataid' => 'timeopen', 'label' => get_string($openlabelid, 'core_course'), 'timestamp' => (int) $timeopen, ]; } if ($timeclose) { $closelabelid = $timeclose > $now ? 'activitydate:closes' : 'activitydate:closed'; $dates[] = [ 'dataid' => 'timeclose', 'label' => get_string($closelabelid, 'core_course'), 'timestamp' => (int) $timeclose, ]; } return $dates; } } PK9B\!K&classes/navigation/views/secondary.phpnu[. namespace mod_quiz\navigation\views; use core\navigation\views\secondary as core_secondary; /** * Class secondary_navigation_view. * * Custom implementation for a plugin. * * @package mod_quiz * @category navigation * @copyright 2021 Sujith Haridasan * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class secondary extends core_secondary { /** * Define a custom secondary nav order/view. * * @return array */ protected function get_default_module_mapping(): array { $defaultmaping = parent::get_default_module_mapping(); $defaultmaping[self::TYPE_SETTING] = array_merge($defaultmaping[self::TYPE_SETTING], [ 'mod_quiz_edit' => 3, 'quiz_report' => 4, 'mod_quiz_useroverrides' => 6, 'roleassign' => 7, 'filtermanage' => 8, 'roleoverride' => 9, 'rolecheck' => 9.1, 'logreport' => 10, 'backup' => 11, 'restore' => 12, 'competencybreakdown' => 13, ]); $defaultmaping[self::TYPE_CONTAINER] = [ 'questionbank' => 5, ]; return $defaultmaping; } } PK9B\C55"classes/local/access_rule_base.phpnu[. namespace mod_quiz\local; use mod_quiz\form\preflight_check_form; use mod_quiz_mod_form; use moodle_page; use MoodleQuickForm; use mod_quiz\quiz_settings; use stdClass; defined('MOODLE_INTERNAL') || die(); require_once($CFG->dirroot . '/mod/quiz/locallib.php'); /** * Base class for rules that restrict the ability to attempt a quiz. * * Quiz access rule plugins must sublclass this one to form their main 'rule' class. * Most of the methods are defined in a slightly unnatural way because we either * want to say that access is allowed, or explain the reason why it is block. * Therefore instead of is_access_allowed(...) we have prevent_access(...) that * return false if access is permitted, or a string explanation (which is treated * as true) if access should be blocked. Slighly unnatural, but actually the easiest * way to implement this. * * @package mod_quiz * @copyright 2009 Tim Hunt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @since Moodle 2.2 */ abstract class access_rule_base { /** @var stdClass the quiz settings. */ protected $quiz; /** @var quiz_settings the quiz object. */ protected $quizobj; /** @var int the time to use as 'now'. */ protected $timenow; /** * Create an instance of this rule for a particular quiz. * * @param quiz_settings $quizobj information about the quiz in question. * @param int $timenow the time that should be considered as 'now'. */ public function __construct($quizobj, $timenow) { $this->quizobj = $quizobj; $this->quiz = $quizobj->get_quiz(); $this->timenow = $timenow; } /** * Return an appropriately configured instance of this rule, if it is applicable * to the given quiz, otherwise return null. * * @param quiz_settings $quizobj information about the quiz in question. * @param int $timenow the time that should be considered as 'now'. * @param bool $canignoretimelimits whether the current user is exempt from * time limits by the mod/quiz:ignoretimelimits capability. * @return self|null the rule, if applicable, else null. */ public static function make(quiz_settings $quizobj, $timenow, $canignoretimelimits) { return null; } /** * Whether a user should be allowed to start a new attempt at this quiz now. * * @param int $numprevattempts the number of previous attempts this user has made. * @param stdClass $lastattempt information about the user's last completed attempt. * @return string false if access should be allowed, a message explaining the * reason if access should be prevented. */ public function prevent_new_attempt($numprevattempts, $lastattempt) { return false; } /** * Whether the user should be blocked from starting a new attempt or continuing * an attempt now. * @return string false if access should be allowed, a message explaining the * reason if access should be prevented. */ public function prevent_access() { return false; } /** * Does this rule require a UI check with the user before an attempt is started? * * @param int|null $attemptid the id of the current attempt, if there is one, * otherwise null. * @return bool whether a check is required before the user starts/continues * their attempt. */ public function is_preflight_check_required($attemptid) { return false; } /** * Add any field you want to pre-flight check form. You should only do * something here if {@see is_preflight_check_required()} returned true. * * @param preflight_check_form $quizform the form being built. * @param MoodleQuickForm $mform The wrapped MoodleQuickForm. * @param int|null $attemptid the id of the current attempt, if there is one, * otherwise null. */ public function add_preflight_check_form_fields(preflight_check_form $quizform, MoodleQuickForm $mform, $attemptid) { // Do nothing by default. } /** * Validate the pre-flight check form submission. You should only do * something here if {@see is_preflight_check_required()} returned true. * * If the form validates, the user will be allowed to continue. * * @param array $data the submitted form data. * @param array $files any files in the submission. * @param array $errors the list of validation errors that is being built up. * @param int|null $attemptid the id of the current attempt, if there is one, * otherwise null. * @return array the update $errors array; */ public function validate_preflight_check($data, $files, $errors, $attemptid) { return $errors; } /** * The pre-flight check has passed. This is a chance to record that fact in * some way. * @param int|null $attemptid the id of the current attempt, if there is one, * otherwise null. */ public function notify_preflight_check_passed($attemptid) { // Do nothing by default. } /** * This is called when the current attempt at the quiz is finished. This is * used, for example by the password rule, to clear the flag in the session. */ public function current_attempt_finished() { // Do nothing by default. } /** * Return a brief summary of this rule, to show to users, if required. * * This information is show shown, for example, on the quiz view page, to explain this * restriction. There is no obligation to return anything. If it is not appropriate to * tell students about this rule, then just return ''. * * @return string a message, or array of messages, explaining the restriction * (may be '' if no message is appropriate). */ public function description() { return ''; } /** * Is the current user unable to start any more attempts in future, because of this rule? * * If this rule can determine that this user will never be allowed another attempt at * this quiz, for example because the last possible start time is past, or all attempts * have been used up, then return true. This is used to know whether to display a * final grade on the view page. This will only be called if there is not a currently * active attempt for this user. * * @param int $numprevattempts the number of previous attempts this user has made. * @param stdClass $lastattempt information about the user's last completed attempt. * @return bool true if this rule means that this user will never be allowed another * attempt at this quiz. */ public function is_finished($numprevattempts, $lastattempt) { return false; } /** * Time by which, according to this rule, the user has to finish their attempt. * * @param stdClass $attempt the current attempt * @return int|false the attempt close time, or false if there is no close time. */ public function end_time($attempt) { return false; } /** * If the user should be shown a different amount of time than $timenow - $this->end_time(), then * override this method. This is useful if the time remaining is large enough to be omitted. * @param stdClass $attempt the current attempt * @param int $timenow the time now. We don't use $this->timenow, so we can * give the user a more accurate indication of how much time is left. * @return mixed the time left in seconds (can be negative) or false if there is no limit. */ public function time_left_display($attempt, $timenow) { $endtime = $this->end_time($attempt); if ($endtime === false) { return false; } return $endtime - $timenow; } /** * Does this rule requires the attempt (and review) to be displayed in a pop-up window? * * @return bool true if it does. */ public function attempt_must_be_in_popup() { return false; } /** * Any options required when showing the attempt in a pop-up. * * @return array any options that are required for showing the attempt page * in a popup window. */ public function get_popup_options() { return []; } /** * Sets up the attempt (review or summary) page with any special extra * properties required by this rule. securewindow rule is an example of where * this is used. * * @param moodle_page $page the page object to initialise. */ public function setup_attempt_page($page) { // Do nothing by default. } /** * It is possible for one rule to override other rules. * * The aim is that third-party rules should be able to replace sandard rules * if they want. See, for example MDL-13592. * * @return array plugin names of other rules that this one replaces. * For example ['ipaddress', 'password']. */ public function get_superceded_rules() { return []; } /** * Add any fields that this rule requires to the quiz settings form. This * method is called from {@see mod_quiz_mod_form::definition()}, while the * security seciton is being built. * @param mod_quiz_mod_form $quizform the quiz settings form that is being built. * @param MoodleQuickForm $mform the wrapped MoodleQuickForm. */ public static function add_settings_form_fields( mod_quiz_mod_form $quizform, MoodleQuickForm $mform) { // By default do nothing. } /** * Validate the data from any form fields added using {@see add_settings_form_fields()}. * @param array $errors the errors found so far. * @param array $data the submitted form data. * @param array $files information about any uploaded files. * @param mod_quiz_mod_form $quizform the quiz form object. * @return array $errors the updated $errors array. */ public static function validate_settings_form_fields(array $errors, array $data, $files, mod_quiz_mod_form $quizform) { return $errors; } /** * Get any options this rule adds to the 'Browser security' quiz setting. * * @return array key => lang string any choices to add to the quiz Browser * security settings menu. */ public static function get_browser_security_choices() { return []; } /** * Save any submitted settings when the quiz settings form is submitted. This * is called from {@see quiz_after_add_or_update()} in lib.php. * @param stdClass $quiz the data from the quiz form, including $quiz->id * which is the id of the quiz being saved. */ public static function save_settings($quiz) { // By default do nothing. } /** * Delete any rule-specific settings when the quiz is deleted. This is called * from {@see quiz_delete_instance()} in lib.php. * @param stdClass $quiz the data from the database, including $quiz->id * which is the id of the quiz being deleted. * @since Moodle 2.7.1, 2.6.4, 2.5.7 */ public static function delete_settings($quiz) { // By default do nothing. } /** * Return the bits of SQL needed to load all the settings from all the access * plugins in one DB query. The easiest way to understand what you need to do * here is probably to read the code of {@see access_manager::load_settings()}. * * If you have some settings that cannot be loaded in this way, then you can * use the {@see get_extra_settings()} method instead, but that has * performance implications. * * @param int $quizid the id of the quiz we are loading settings for. This * can also be accessed as quiz.id in the SQL. (quiz is a table alisas for {quiz}.) * @return array with three elements: * 1. fields: any fields to add to the select list. These should be alised * if neccessary so that the field name starts the name of the plugin. * 2. joins: any joins (should probably be LEFT JOINS) with other tables that * are needed. * 3. params: array of placeholder values that are needed by the SQL. You must * used named placeholders, and the placeholder names should start with the * plugin name, to avoid collisions. */ public static function get_settings_sql($quizid) { return ['', '', []]; } /** * You can use this method to load any extra settings your plugin has that * cannot be loaded efficiently with get_settings_sql(). * @param int $quizid the quiz id. * @return array setting value name => value. The value names should all * start with the name of your plugin to avoid collisions. */ public static function get_extra_settings($quizid) { return []; } } PK9B\<?'classes/local/structure/slot_random.phpnu[. namespace mod_quiz\local\structure; use context_module; /** * Class slot_random, represents a random question slot type. * * @package mod_quiz * @copyright 2018 Shamim Rezaie * @author 2021 Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class slot_random { /** @var \stdClass Slot's properties. A record retrieved from the quiz_slots table. */ protected $record; /** * @var \stdClass set reference record */ protected $referencerecord; /** * @var \stdClass The quiz this question slot belongs to. */ protected $quiz = null; /** * @var \core_tag_tag[] List of tags for this slot. */ protected $tags = []; /** * @var string filter condition */ protected $filtercondition = null; /** * slot_random constructor. * * @param \stdClass $slotrecord Represents a record in the quiz_slots table. */ public function __construct($slotrecord = null) { $this->record = new \stdClass(); $this->referencerecord = new \stdClass(); $slotproperties = ['id', 'slot', 'quizid', 'page', 'requireprevious', 'maxmark']; $setreferenceproperties = ['usingcontextid', 'questionscontextid']; foreach ($slotproperties as $property) { if (isset($slotrecord->$property)) { $this->record->$property = $slotrecord->$property; } } foreach ($setreferenceproperties as $referenceproperty) { if (isset($slotrecord->$referenceproperty)) { $this->referencerecord->$referenceproperty = $slotrecord->$referenceproperty; } } } /** * Returns the quiz for this question slot. * The quiz is fetched the first time it is requested and then stored in a member variable to be returned each subsequent time. * * @return mixed * @throws \coding_exception */ public function get_quiz() { global $DB; if (empty($this->quiz)) { if (empty($this->record->quizid)) { throw new \coding_exception('quizid is not set.'); } $this->quiz = $DB->get_record('quiz', ['id' => $this->record->quizid]); } return $this->quiz; } /** * Sets the quiz object for the quiz slot. * It is not mandatory to set the quiz as the quiz slot can fetch it the first time it is accessed, * however it helps with the performance to set the quiz if you already have it. * * @param \stdClass $quiz The qui object. */ public function set_quiz($quiz) { $this->quiz = $quiz; $this->record->quizid = $quiz->id; } /** * Set some tags for this quiz slot. * * @param \core_tag_tag[] $tags * * @deprecated since Moodle 4.3 * @todo Final deprecation on Moodle 4.7 MDL-78091 */ public function set_tags($tags) { debugging('Method set_tags() is deprecated, ' . 'please do not use this function.', DEBUG_DEVELOPER); $this->tags = []; foreach ($tags as $tag) { // We use $tag->id as the key for the array so not only it handles duplicates of the same tag being given, // but also it is consistent with the behaviour of set_tags_by_id() below. $this->tags[$tag->id] = $tag; } } /** * Set some tags for this quiz slot. This function uses tag ids to find tags. * * @param int[] $tagids * @deprecated since Moodle 4.3 * @todo Final deprecation on Moodle 4.7 MDL-78091 */ public function set_tags_by_id($tagids) { debugging( 'Method set_tags_by_id() is deprecated, please do not use this function.', DEBUG_DEVELOPER ); $this->tags = \core_tag_tag::get_bulk($tagids, 'id, name'); } /** * Set filter condition. * * @param \string $filters */ public function set_filter_condition(string $filters): void { $this->filtercondition = $filters; } /** * Inserts the quiz slot at the $page page. * It is required to call this function if you are building a quiz slot object from scratch. * * @param int $page The page that this slot will be inserted at. */ public function insert($page) { global $DB; $slots = $DB->get_records('quiz_slots', ['quizid' => $this->record->quizid], 'slot', 'id, slot, page'); $quiz = $this->get_quiz(); $trans = $DB->start_delegated_transaction(); $maxpage = 1; $numonlastpage = 0; foreach ($slots as $slot) { if ($slot->page > $maxpage) { $maxpage = $slot->page; $numonlastpage = 1; } else { $numonlastpage += 1; } } if (is_int($page) && $page >= 1) { // Adding on a given page. $lastslotbefore = 0; foreach (array_reverse($slots) as $otherslot) { if ($otherslot->page > $page) { $DB->set_field('quiz_slots', 'slot', $otherslot->slot + 1, ['id' => $otherslot->id]); } else { $lastslotbefore = $otherslot->slot; break; } } $this->record->slot = $lastslotbefore + 1; $this->record->page = min($page, $maxpage + 1); quiz_update_section_firstslots($this->record->quizid, 1, max($lastslotbefore, 1)); } else { $lastslot = end($slots); if ($lastslot) { $this->record->slot = $lastslot->slot + 1; } else { $this->record->slot = 1; } if ($quiz->questionsperpage && $numonlastpage >= $quiz->questionsperpage) { $this->record->page = $maxpage + 1; } else { $this->record->page = $maxpage; } } $this->record->id = $DB->insert_record('quiz_slots', $this->record); $this->referencerecord->component = 'mod_quiz'; $this->referencerecord->questionarea = 'slot'; $this->referencerecord->itemid = $this->record->id; $this->referencerecord->filtercondition = $this->filtercondition; $DB->insert_record('question_set_references', $this->referencerecord); $trans->allow_commit(); // Log slot created event. $cm = get_coursemodule_from_instance('quiz', $quiz->id); $event = \mod_quiz\event\slot_created::create([ 'context' => context_module::instance($cm->id), 'objectid' => $this->record->id, 'other' => [ 'quizid' => $quiz->id, 'slotnumber' => $this->record->slot, 'page' => $this->record->page ] ]); $event->trigger(); } } PK9B\5= %classes/local/reports/report_base.phpnu[. namespace mod_quiz\local\reports; use context; use context_module; use stdClass; /** * Base class for quiz report plugins. * * Doesn't do anything on its own -- it needs to be extended. * This class displays quiz reports. Because it is called from * within /mod/quiz/report.php you can assume that the page header * and footer are taken care of. * * This file can refer to itself as report.php to pass variables * to itself - all these will also be globally available. You must * pass "id=$cm->id" or q=$quiz->id", and "mode=reportname". * * @package mod_quiz * @copyright 1999 onwards Martin Dougiamas and others {@link http://moodle.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ abstract class report_base { /** @var int special value used in place of groupid, to mean the use cannot access any groups. */ const NO_GROUPS_ALLOWED = -2; /** * Override this function to display the report. * * @param stdClass $quiz this quiz. * @param stdClass $cm the course-module for this quiz. * @param stdClass $course the coures we are in. */ abstract public function display($quiz, $cm, $course); /** * Initialise some parts of $PAGE and start output. * * @param stdClass $cm the course_module information. * @param stdClass $course the course settings. * @param stdClass $quiz the quiz settings. * @param string $reportmode the report name. */ public function print_header_and_tabs($cm, $course, $quiz, $reportmode = 'overview') { global $PAGE, $OUTPUT, $CFG; // Print the page header. $PAGE->set_title($quiz->name); $PAGE->set_heading($course->fullname); echo $OUTPUT->header(); $context = context_module::instance($cm->id); if (!$PAGE->has_secondary_navigation()) { echo $OUTPUT->heading(format_string($quiz->name, true, ['context' => $context])); } } /** * Get the current group for the user user looking at the report. * * @param stdClass $cm the course_module information. * @param stdClass $course the course settings. * @param context $context the quiz context. * @return int the current group id, if applicable. 0 for all users, * NO_GROUPS_ALLOWED if the user cannot see any group. */ public function get_current_group($cm, $course, $context) { $groupmode = groups_get_activity_groupmode($cm, $course); $currentgroup = groups_get_activity_group($cm, true); if ($groupmode == SEPARATEGROUPS && !$currentgroup && !has_capability('moodle/site:accessallgroups', $context)) { $currentgroup = self::NO_GROUPS_ALLOWED; } return $currentgroup; } } PK9B\\!}}/classes/local/reports/attempts_report_table.phpnu[. namespace mod_quiz\local\reports; defined('MOODLE_INTERNAL') || die(); require_once($CFG->libdir.'/tablelib.php'); use coding_exception; use context_module; use html_writer; use mod_quiz\quiz_attempt; use mod_quiz\quiz_settings; use moodle_url; use popup_action; use question_state; use qubaid_condition; use qubaid_join; use qubaid_list; use question_engine_data_mapper; use stdClass; /** * Base class for the table used by a {@see attempts_report}. * * @package mod_quiz * @copyright 2010 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ abstract class attempts_report_table extends \table_sql { public $useridfield = 'userid'; /** @var moodle_url the URL of this report. */ protected $reporturl; /** @var array the display options. */ protected $displayoptions; /** * @var array information about the latest step of each question. * Loaded by {@see load_question_latest_steps()}, if applicable. */ protected $lateststeps = null; /** * @var float[][]|null total mark for each grade item. Array question_usage.id => quiz_grade_item.id => mark. * Loaded by {@see load_grade_item_marks()}, if applicable. */ protected ?array $gradeitemtotals = null; /** @var stdClass the quiz settings for the quiz we are reporting on. */ protected $quiz; /** @var quiz_settings quiz settings object for this quiz. Gets set in {@see attempts_report::et_up_table_columns()}. */ protected quiz_settings $quizobj; /** @var context_module the quiz context. */ protected $context; /** @var string HTML fragment to select the first/best/last attempt, if appropriate. */ protected $qmsubselect; /** @var stdClass attempts_report_options the options affecting this report. */ protected $options; /** @var \core\dml\sql_join Contains joins, wheres, params to find students * in the currently selected group, if applicable. */ protected $groupstudentsjoins; /** @var \core\dml\sql_join Contains joins, wheres, params to find the students in the course. */ protected $studentsjoins; /** @var array the questions that comprise this quiz. */ protected $questions; /** @var bool whether to include the column with checkboxes to select each attempt. */ protected $includecheckboxes; /** @var string The toggle group name for the checkboxes in the checkbox column. */ protected $togglegroup = 'quiz-attempts'; /** @var string strftime format. */ protected $strtimeformat; /** @var bool|null used by {@see col_state()} to cache the has_capability result. */ protected $canreopen = null; /** * Constructor. * * @param string $uniqueid * @param stdClass $quiz * @param context_module $context * @param string $qmsubselect * @param attempts_report_options $options * @param \core\dml\sql_join $groupstudentsjoins Contains joins, wheres, params * @param \core\dml\sql_join $studentsjoins Contains joins, wheres, params * @param array $questions * @param moodle_url $reporturl */ public function __construct($uniqueid, $quiz, $context, $qmsubselect, attempts_report_options $options, \core\dml\sql_join $groupstudentsjoins, \core\dml\sql_join $studentsjoins, $questions, $reporturl) { parent::__construct($uniqueid); $this->quiz = $quiz; $this->context = $context; $this->qmsubselect = $qmsubselect; $this->groupstudentsjoins = $groupstudentsjoins; $this->studentsjoins = $studentsjoins; $this->questions = $questions; $this->includecheckboxes = $options->checkboxcolumn; $this->reporturl = $reporturl; $this->options = $options; } /** * A way for the report to pass in the quiz settings object. Currently done in {@see attempts_report::set_up_table_columns()}. * * @param quiz_settings $quizobj */ public function set_quiz_setting(quiz_settings $quizobj): void { $this->quizobj = $quizobj; } /** * Generate the display of the checkbox column. * * @param stdClass $attempt the table row being output. * @return string HTML content to go inside the td. */ public function col_checkbox($attempt) { global $OUTPUT; if ($attempt->attempt) { $checkbox = new \core\output\checkbox_toggleall($this->togglegroup, false, [ 'id' => "attemptid_{$attempt->attempt}", 'name' => 'attemptid[]', 'value' => $attempt->attempt, 'label' => get_string('selectattempt', 'quiz'), 'labelclasses' => 'accesshide', ]); return $OUTPUT->render($checkbox); } else { return ''; } } /** * Generate the display of the user's picture column. * * @param stdClass $attempt the table row being output. * @return string HTML content to go inside the td. */ public function col_picture($attempt) { global $OUTPUT; $user = new stdClass(); $additionalfields = explode(',', implode(',', \core_user\fields::get_picture_fields())); $user = username_load_fields_from_object($user, $attempt, null, $additionalfields); $user->id = $attempt->userid; return $OUTPUT->user_picture($user); } /** * Generate the display of the user's full name column. * * @param stdClass $attempt the table row being output. * @return string HTML content to go inside the td. */ public function col_fullname($attempt) { $html = parent::col_fullname($attempt); if ($this->is_downloading() || empty($attempt->attempt)) { return $html; } return $html . html_writer::empty_tag('br') . html_writer::link( new moodle_url('/mod/quiz/review.php', ['attempt' => $attempt->attempt]), get_string('reviewattempt', 'quiz'), ['class' => 'reviewlink']); } /** * Generate the display of the attempt state column. * * @param stdClass $attempt the table row being output. * @return string HTML content to go inside the td. */ public function col_state($attempt) { if (is_null($attempt->attempt)) { return '-'; } $display = quiz_attempt::state_name($attempt->state); if ($this->is_downloading()) { return $display; } $this->canreopen ??= has_capability('mod/quiz:reopenattempts', $this->context); if ($attempt->state == quiz_attempt::ABANDONED && $this->canreopen) { $display .= ' ' . html_writer::tag('button', get_string('reopenattempt', 'quiz'), [ 'type' => 'button', 'class' => 'btn btn-secondary', 'data-action' => 'reopen-attempt', 'data-attempt-id' => $attempt->attempt, 'data-after-action-url' => $this->reporturl->out_as_local_url(false), ]); } return $display; } /** * Generate the display of the start time column. * * @param stdClass $attempt the table row being output. * @return string HTML content to go inside the td. */ public function col_timestart($attempt) { if ($attempt->attempt && $attempt->timestart) { return userdate($attempt->timestart, $this->strtimeformat); } else { return '-'; } } /** * Generate the display of the finish time column. * * @param stdClass $attempt the table row being output. * @return string HTML content to go inside the td. */ public function col_timefinish($attempt) { if ($attempt->attempt && $attempt->timefinish) { return userdate($attempt->timefinish, $this->strtimeformat); } else { return '-'; } } /** * Generate the display of the duration column. * * @param stdClass $attempt the table row being output. * @return string HTML content to go inside the td. */ public function col_duration($attempt) { if ($attempt->timefinish) { return format_time($attempt->timefinish - $attempt->timestart); } else { return '-'; } } /** * Generate the display of the feedback column. * * @param stdClass $attempt the table row being output. * @return string HTML content to go inside the td. */ public function col_feedbacktext($attempt) { if ($attempt->state != quiz_attempt::FINISHED) { return '-'; } $feedback = quiz_report_feedback_for_grade( quiz_rescale_grade($attempt->sumgrades, $this->quiz, false), $this->quiz->id, $this->context); if ($this->is_downloading()) { $feedback = strip_tags($feedback); } return $feedback; } public function other_cols($colname, $attempt) { $gradeitemid = $this->is_grade_item_column($colname); if (!$gradeitemid) { return parent::other_cols($colname, $attempt); } if (isset($this->gradeitemtotals[$attempt->usageid][$gradeitemid])) { $grade = quiz_format_grade($this->quiz, $this->gradeitemtotals[$attempt->usageid][$gradeitemid]); return $grade; } else { return '-'; } } /** * Is this the column key for an extra grade item column? * * @param string $columnname e.g. 'marks123' or 'duration'. * @return int grade item id if this is a column for showing that grade item grade, else, 0. */ protected function is_grade_item_column(string $columnname): int { if (preg_match('/^marks(\d+)$/', $columnname, $matches)) { return $matches[1]; } return 0; } public function get_row_class($attempt) { if ($this->qmsubselect && $attempt->gradedattempt) { return 'gradedattempt'; } else { return ''; } } /** * Make a link to review an individual question in a popup window. * * @param string $data HTML fragment. The text to make into the link. * @param stdClass $attempt data for the row of the table being output. * @param int $slot the number used to identify this question within this usage. */ public function make_review_link($data, $attempt, $slot) { global $OUTPUT, $CFG; $flag = ''; if ($this->is_flagged($attempt->usageid, $slot)) { $flag = $OUTPUT->pix_icon('i/flagged', get_string('flagged', 'question'), 'moodle', ['class' => 'questionflag']); } $feedbackimg = ''; $state = $this->slot_state($attempt, $slot); if ($state && $state->is_finished() && $state != question_state::$needsgrading) { $feedbackimg = $this->icon_for_fraction($this->slot_fraction($attempt, $slot)); } $output = html_writer::tag('span', $feedbackimg . html_writer::tag('span', $data, ['class' => $state->get_state_class(true)]) . $flag, ['class' => 'que']); $reviewparams = ['attempt' => $attempt->attempt, 'slot' => $slot]; if (isset($attempt->try)) { $reviewparams['step'] = $this->step_no_for_try($attempt->usageid, $slot, $attempt->try); } $url = new moodle_url('/mod/quiz/reviewquestion.php', $reviewparams); $output = $OUTPUT->action_link($url, $output, new popup_action('click', $url, 'reviewquestion', ['height' => 450, 'width' => 650]), ['title' => get_string('reviewresponse', 'quiz')]); if (!empty($CFG->enableplagiarism)) { require_once($CFG->libdir . '/plagiarismlib.php'); $output .= plagiarism_get_links([ 'context' => $this->context->id, 'component' => 'qtype_'.$this->questions[$slot]->qtype, 'cmid' => $this->context->instanceid, 'area' => $attempt->usageid, 'itemid' => $slot, 'userid' => $attempt->userid]); } return $output; } /** * Get the question attempt state for a particular question in a particular quiz attempt. * * @param stdClass $attempt the row data. * @param int $slot indicates which question. * @return question_state the state of that question. */ protected function slot_state($attempt, $slot) { $stepdata = $this->lateststeps[$attempt->usageid][$slot]; return question_state::get($stepdata->state); } /** * Work out if a particular question in a particular attempt has been flagged. * * @param int $questionusageid used to identify the attempt of interest. * @param int $slot identifies which question in the attempt to check. * @return bool true if the question is flagged in the attempt. */ protected function is_flagged($questionusageid, $slot) { $stepdata = $this->lateststeps[$questionusageid][$slot]; return $stepdata->flagged; } /** * Get the mark (out of 1) for the question in a particular slot. * * @param stdClass $attempt the row data * @param int $slot which slot to check. * @return float the score for this question on a scale of 0 - 1. */ protected function slot_fraction($attempt, $slot) { $stepdata = $this->lateststeps[$attempt->usageid][$slot]; return $stepdata->fraction; } /** * Return an appropriate icon (green tick, red cross, etc.) for a grade. * * @param float $fraction grade on a scale 0..1. * @return string html fragment. */ protected function icon_for_fraction($fraction) { global $OUTPUT; $feedbackclass = question_state::graded_state_for_fraction($fraction)->get_feedback_class(); return $OUTPUT->pix_icon('i/grade_' . $feedbackclass, get_string($feedbackclass, 'question'), 'moodle', ['class' => 'icon']); } /** * Load any extra data after main query. * * At this point you can call {@see get_qubaids_condition} to get the condition * that limits the query to just the question usages shown in this report page or * alternatively for all attempts if downloading a full report. */ protected function load_extra_data() { $this->lateststeps = $this->load_question_latest_steps(); } /** * Load the total mark for each grade item for each attempt. */ protected function load_grade_item_marks(): void { if (count($this->rawdata) === 0) { $this->gradeitemtotals = []; return; } $this->gradeitemtotals = $this->quizobj->get_grade_calculator()->load_grade_item_totals( $this->get_qubaids_condition()); } /** * Load information about the latest state of selected questions in selected attempts. * * The results are returned as a two-dimensional array $qubaid => $slot => $dataobject. * * @param qubaid_condition|null $qubaids used to restrict which usages are included * in the query. See {@see qubaid_condition}. * @return array of records. See the SQL in this function to see the fields available. */ protected function load_question_latest_steps(?qubaid_condition $qubaids = null) { if ($qubaids === null) { $qubaids = $this->get_qubaids_condition(); } $dm = new question_engine_data_mapper(); $latesstepdata = $dm->load_questions_usages_latest_steps( $qubaids, array_keys($this->questions)); $lateststeps = []; foreach ($latesstepdata as $step) { $lateststeps[$step->questionusageid][$step->slot] = $step; } return $lateststeps; } /** * Does this report require loading any more data after the main query. * * @return bool should {@see query_db()} call {@see load_extra_data}? */ protected function requires_extra_data() { return $this->requires_latest_steps_loaded(); } /** * Does this report require the detailed information for each question from the question_attempts_steps table? * * @return bool should {@see load_extra_data} call {@see load_question_latest_steps}? */ protected function requires_latest_steps_loaded() { return false; } /** * Is this a column that depends on joining to the latest state information? * * If so, return the corresponding slot. If not, return false. * * @param string $column a column name * @return int|false false if no, else a slot. */ protected function is_latest_step_column($column) { return false; } /** * Get any fields that might be needed when sorting on date for a particular slot. * * Note: these values are only used for sorting. The values displayed are taken * from $this->lateststeps loaded in load_extra_data(). * * @param int $slot the slot for the column we want. * @param string $alias the table alias for latest state information relating to that slot. * @return string definitions of extra fields to add to the SELECT list of the query. */ protected function get_required_latest_state_fields($slot, $alias) { return ''; } /** * Contruct all the parts of the main database query. * * @param \core\dml\sql_join $allowedstudentsjoins (joins, wheres, params) defines allowed users for the report. * @return array with 4 elements [$fields, $from, $where, $params] that can be used to * build the actual database query. */ public function base_sql(\core\dml\sql_join $allowedstudentsjoins) { global $DB; // Please note this uniqueid column is not the same as quiza.uniqueid. $fields = 'DISTINCT ' . $DB->sql_concat('u.id', "'#'", 'COALESCE(quiza.attempt, 0)') . ' AS uniqueid,'; if ($this->qmsubselect) { $fields .= "\n(CASE WHEN $this->qmsubselect THEN 1 ELSE 0 END) AS gradedattempt,"; } $userfieldsapi = \core_user\fields::for_identity($this->context)->with_name() ->excluding('id', 'idnumber', 'picture', 'imagealt', 'institution', 'department', 'email'); $userfields = $userfieldsapi->get_sql('u', true, '', '', false); $fields .= ' quiza.uniqueid AS usageid, quiza.id AS attempt, u.id AS userid, u.idnumber, u.picture, u.imagealt, u.institution, u.department, u.email,' . $userfields->selects . ', quiza.state, quiza.sumgrades, quiza.timefinish, quiza.timestart, CASE WHEN quiza.timefinish = 0 THEN null WHEN quiza.timefinish > quiza.timestart THEN quiza.timefinish - quiza.timestart ELSE 0 END AS duration'; // To explain that last bit, timefinish can be non-zero and less // than timestart when you have two load-balanced servers with very // badly synchronised clocks, and a student does a really quick attempt. // This part is the same for all cases. Join the users and quiz_attempts tables. $from = " {user} u"; $from .= "\n{$userfields->joins}"; $from .= "\nLEFT JOIN {quiz_attempts} quiza ON quiza.userid = u.id AND quiza.quiz = :quizid"; $params = array_merge($userfields->params, ['quizid' => $this->quiz->id]); if ($this->qmsubselect && $this->options->onlygraded) { $from .= " AND (quiza.state <> :finishedstate OR $this->qmsubselect)"; $params['finishedstate'] = quiz_attempt::FINISHED; } switch ($this->options->attempts) { case attempts_report::ALL_WITH: // Show all attempts, including students who are no longer in the course. $where = 'quiza.id IS NOT NULL AND quiza.preview = 0'; break; case attempts_report::ENROLLED_WITH: // Show only students with attempts. $from .= "\n" . $allowedstudentsjoins->joins; $where = "quiza.preview = 0 AND quiza.id IS NOT NULL AND " . $allowedstudentsjoins->wheres; $params = array_merge($params, $allowedstudentsjoins->params); break; case attempts_report::ENROLLED_WITHOUT: // Show only students without attempts. $from .= "\n" . $allowedstudentsjoins->joins; $where = "quiza.id IS NULL AND " . $allowedstudentsjoins->wheres; $params = array_merge($params, $allowedstudentsjoins->params); break; case attempts_report::ENROLLED_ALL: // Show all students with or without attempts. $from .= "\n" . $allowedstudentsjoins->joins; $where = "(quiza.preview = 0 OR quiza.preview IS NULL) AND " . $allowedstudentsjoins->wheres; $params = array_merge($params, $allowedstudentsjoins->params); break; } if ($this->options->states) { [$statesql, $stateparams] = $DB->get_in_or_equal($this->options->states, SQL_PARAMS_NAMED, 'state'); $params += $stateparams; $where .= " AND (quiza.state $statesql OR quiza.state IS NULL)"; } return [$fields, $from, $where, $params]; } /** * Lets subclasses modify the SQL after the count query has been created and before the full query is. * * @param string $fields SELECT list. * @param string $from JOINs part of the SQL. * @param string $where WHERE clauses. * @param array $params Query params. * @return array with 4 elements ($fields, $from, $where, $params) as from base_sql. */ protected function update_sql_after_count($fields, $from, $where, $params) { return [$fields, $from, $where, $params]; } /** * Set up the SQL queries (count rows, and get data). * * @param \core\dml\sql_join $allowedjoins (joins, wheres, params) defines allowed users for the report. */ public function setup_sql_queries($allowedjoins) { [$fields, $from, $where, $params] = $this->base_sql($allowedjoins); // The WHERE clause is vital here, because some parts of tablelib.php will expect to // add bits like ' AND x = 1' on the end, and that needs to leave to valid SQL. $this->set_count_sql("SELECT COUNT(1) FROM (SELECT $fields FROM $from WHERE $where) temp WHERE 1 = 1", $params); [$fields, $from, $where, $params] = $this->update_sql_after_count($fields, $from, $where, $params); $this->set_sql($fields, $from, $where, $params); } /** * Add the information about the latest state of the question with slot * $slot to the query. * * The extra information is added as a join to a * 'table' with alias qa$slot, with columns that are a union of * the columns of the question_attempts and question_attempts_states tables. * * @param int $slot the question to add information for. */ protected function add_latest_state_join($slot) { $alias = 'qa' . $slot; $fields = $this->get_required_latest_state_fields($slot, $alias); if (!$fields) { return; } // This condition roughly filters the list of attempts to be considered. // It is only used in a sub-select to help database query optimisers (see MDL-30122). // Therefore, it is better to use a very simple which may include // too many records, than to do a super-accurate join. $qubaids = new qubaid_join("{quiz_attempts} {$alias}quiza", "{$alias}quiza.uniqueid", "{$alias}quiza.quiz = :{$alias}quizid", ["{$alias}quizid" => $this->sql->params['quizid']]); $dm = new question_engine_data_mapper(); [$inlineview, $viewparams] = $dm->question_attempt_latest_state_view($alias, $qubaids); $this->sql->fields .= ",\n$fields"; $this->sql->from .= "\nLEFT JOIN $inlineview ON " . "$alias.questionusageid = quiza.uniqueid AND $alias.slot = :{$alias}slot"; $this->sql->params[$alias . 'slot'] = $slot; $this->sql->params = array_merge($this->sql->params, $viewparams); } /** * Add a field marks$gradeitemid to the query, with the total score for that grade item. * * @param int $gradeitemid the grade item to add information for. */ protected function add_grade_item_mark(int $gradeitemid): void { $dm = new question_engine_data_mapper(); $alias = 'gimarks' . $gradeitemid; $this->sql->fields .= ",\n( SELECT SUM({$alias}qas.fraction * {$alias}qa.maxmark) AS summarks FROM {quiz_slots} {$alias}slot JOIN {question_attempts} {$alias}qa ON {$alias}qa.slot = {$alias}slot.slot JOIN {question_attempt_steps} {$alias}qas ON {$alias}qas.questionattemptid = {$alias}qa.id AND {$alias}qas.sequencenumber = {$dm->latest_step_for_qa_subquery("{$alias}qa.id")} WHERE {$alias}qa.questionusageid = quiza.uniqueid AND {$alias}slot.quizgradeitemid = :{$alias}gradeitemid ) AS marks$gradeitemid"; $this->sql->params[$alias . 'gradeitemid'] = $gradeitemid; } /** * Get an appropriate qubaid_condition for loading more data about the attempts we are displaying. * * @return qubaid_condition */ protected function get_qubaids_condition() { if (is_null($this->rawdata)) { throw new coding_exception( 'Cannot call get_qubaids_condition until the main data has been loaded.'); } if ($this->is_downloading()) { // We want usages for all attempts. return new qubaid_join("( SELECT DISTINCT quiza.uniqueid FROM " . $this->sql->from . " WHERE " . $this->sql->where . " ) quizasubquery", 'quizasubquery.uniqueid', "1 = 1", $this->sql->params); } $qubaids = []; foreach ($this->rawdata as $attempt) { if ($attempt->usageid > 0) { $qubaids[] = $attempt->usageid; } } return new qubaid_list($qubaids); } public function query_db($pagesize, $useinitialsbar = true) { $doneslots = []; $donegradeitems = []; foreach ($this->get_sort_columns() as $column => $notused) { $slot = $this->is_latest_step_column($column); if ($slot && !in_array($slot, $doneslots)) { $this->add_latest_state_join($slot); $doneslots[] = $slot; } $gradeitemid = $this->is_grade_item_column($column); if ($gradeitemid && !in_array($gradeitemid, $donegradeitems)) { $this->add_grade_item_mark($gradeitemid); $donegradeitems[] = $gradeitemid; } } parent::query_db($pagesize, $useinitialsbar); // Load grade-item totals if required. foreach ($this->columns as $columnname => $notused) { if ($this->is_grade_item_column($columnname)) { $this->load_grade_item_marks(); break; } } if ($this->requires_extra_data()) { $this->load_extra_data(); } } public function get_sort_columns() { // Add attemptid as a final tie-break to the sort. This ensures that // Attempts by the same student appear in order when just sorting by name. $sortcolumns = parent::get_sort_columns(); $sortcolumns['quiza.id'] = SORT_ASC; return $sortcolumns; } public function wrap_html_start() { if ($this->is_downloading() || !$this->includecheckboxes) { return; } $url = $this->options->get_url(); $url->param('sesskey', sesskey()); echo '
'; echo '
'; echo html_writer::input_hidden_params($url); echo '
'; } public function wrap_html_finish() { global $PAGE; if ($this->is_downloading() || !$this->includecheckboxes) { return; } echo '
'; $this->submit_buttons(); echo '
'; // Close the form. echo '
'; echo '
'; } /** * Output any submit buttons required by the $this->includecheckboxes form. */ protected function submit_buttons() { global $PAGE; if (has_capability('mod/quiz:deleteattempts', $this->context)) { $deletebuttonparams = [ 'type' => 'submit', 'class' => 'btn btn-secondary me-1', 'id' => 'deleteattemptsbutton', 'name' => 'delete', 'value' => get_string('deleteselected', 'quiz_overview'), 'data-action' => 'toggle', 'data-togglegroup' => $this->togglegroup, 'data-toggle' => 'action', 'disabled' => true, 'data-modal' => 'confirmation', 'data-modal-type' => 'delete', 'data-modal-content-str' => json_encode(['deleteattemptcheck', 'quiz']), ]; echo html_writer::empty_tag('input', $deletebuttonparams); } } /** * Generates the contents for the checkbox column header. * * It returns the HTML for a master \core\output\checkbox_toggleall component that selects/deselects all quiz attempts. * * @param string $columnname The name of the checkbox column. * @return string */ public function checkbox_col_header(string $columnname) { global $OUTPUT; // Make sure to disable sorting on this column. $this->no_sorting($columnname); // Build the select/deselect all control. $selectallid = $this->uniqueid . '-selectall-attempts'; $selectalltext = get_string('selectall', 'quiz'); $deselectalltext = get_string('selectnone', 'quiz'); $mastercheckbox = new \core\output\checkbox_toggleall($this->togglegroup, true, [ 'id' => $selectallid, 'name' => $selectallid, 'value' => 1, 'label' => $selectalltext, 'labelclasses' => 'accesshide', 'selectall' => $selectalltext, 'deselectall' => $deselectalltext, ]); return $OUTPUT->render($mastercheckbox); } } PK9B\c776classes/local/reports/attempts_report_options_form.phpnu[. namespace mod_quiz\local\reports; use html_writer; use MoodleQuickForm; defined('MOODLE_INTERNAL') || die(); require_once($CFG->libdir . '/formslib.php'); /** * Base class for the settings form for {@see attempts_report}s. * * @package mod_quiz * @copyright 2012 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ abstract class attempts_report_options_form extends \moodleform { protected function definition() { $mform = $this->_form; $mform->addElement('header', 'preferencespage', get_string('reportwhattoinclude', 'quiz')); $this->standard_attempt_fields($mform); $this->other_attempt_fields($mform); $mform->addElement('header', 'preferencesuser', get_string('reportdisplayoptions', 'quiz')); $this->standard_preference_fields($mform); $this->other_preference_fields($mform); $mform->addElement('submit', 'submitbutton', get_string('showreport', 'quiz')); } /** * Add the standard form fields for selecting which attempts to include in the report. * * @param MoodleQuickForm $mform the form we are building. */ protected function standard_attempt_fields(MoodleQuickForm $mform) { $mform->addElement('select', 'attempts', get_string('reportattemptsfrom', 'quiz'), [ attempts_report::ENROLLED_WITH => get_string('reportuserswith', 'quiz'), attempts_report::ENROLLED_WITHOUT => get_string('reportuserswithout', 'quiz'), attempts_report::ENROLLED_ALL => get_string('reportuserswithorwithout', 'quiz'), attempts_report::ALL_WITH => get_string('reportusersall', 'quiz'), ]); $stategroup = [ $mform->createElement('advcheckbox', 'statenotstarted', '', get_string('statenotstarted', 'quiz')), $mform->createElement('advcheckbox', 'stateinprogress', '', get_string('stateinprogress', 'quiz')), $mform->createElement('advcheckbox', 'stateoverdue', '', get_string('stateoverdue', 'quiz')), $mform->createElement('advcheckbox', 'statesubmitted', '', get_string('statesubmitted', 'quiz')), $mform->createElement('advcheckbox', 'statefinished', '', get_string('statefinished', 'quiz')), $mform->createElement('advcheckbox', 'stateabandoned', '', get_string('stateabandoned', 'quiz')), ]; $mform->addGroup($stategroup, 'stateoptions', get_string('reportattemptsthatare', 'quiz'), [' '], false); $mform->addHelpButton('stateoptions', 'stateoptions', 'quiz'); $mform->setDefault('statenotstarted', 1); $mform->setDefault('stateinprogress', 1); $mform->setDefault('stateoverdue', 1); $mform->setDefault('statesubmitted', 1); $mform->setDefault('statefinished', 1); $mform->setDefault('stateabandoned', 1); $mform->disabledIf('statenotstarted', 'attempts', 'eq', attempts_report::ENROLLED_WITHOUT); $mform->disabledIf('stateinprogress', 'attempts', 'eq', attempts_report::ENROLLED_WITHOUT); $mform->disabledIf('stateoverdue', 'attempts', 'eq', attempts_report::ENROLLED_WITHOUT); $mform->disabledIf('statesubmitted', 'attempts', 'eq', attempts_report::ENROLLED_WITHOUT); $mform->disabledIf('statefinished', 'attempts', 'eq', attempts_report::ENROLLED_WITHOUT); $mform->disabledIf('stateabandoned', 'attempts', 'eq', attempts_report::ENROLLED_WITHOUT); if (quiz_report_can_filter_only_graded($this->_customdata['quiz'])) { $gm = html_writer::tag('span', quiz_get_grading_option_name($this->_customdata['quiz']->grademethod), ['class' => 'highlight']); $mform->addElement('advcheckbox', 'onlygraded', '', get_string('reportshowonlyfinished', 'quiz', $gm)); $mform->disabledIf('onlygraded', 'attempts', 'eq', attempts_report::ENROLLED_WITHOUT); $mform->disabledIf('onlygraded', 'statefinished', 'notchecked'); } } /** * Extension point to allow subclasses to add their own fields in the attempts section. * * @param MoodleQuickForm $mform the form we are building. */ protected function other_attempt_fields(MoodleQuickForm $mform) { } /** * Add the standard options fields to the form. * * @param MoodleQuickForm $mform the form we are building. */ protected function standard_preference_fields(MoodleQuickForm $mform) { $mform->addElement('text', 'pagesize', get_string('pagesize', 'quiz')); $mform->setType('pagesize', PARAM_INT); } /** * Extension point to allow subclasses to add their own fields in the options section. * * @param MoodleQuickForm $mform the form we are building. */ protected function other_preference_fields(MoodleQuickForm $mform) { } public function validation($data, $files) { $errors = parent::validation($data, $files); if ( $data['attempts'] != attempts_report::ENROLLED_WITHOUT && !( $data['stateinprogress'] || $data['stateoverdue'] || $data['statefinished'] || $data['stateabandoned'] || $data['statenotstarted'] || $data['statesubmitted'] ) ) { $errors['stateoptions'] = get_string('reportmustselectstate', 'quiz'); } return $errors; } } PK9B\Η((1classes/local/reports/attempts_report_options.phpnu[. namespace mod_quiz\local\reports; use context_module; use mod_quiz\quiz_attempt; use moodle_url; use stdClass; /** * Base class for the options that control what is visible in an {@see attempts_report}. * * @package mod_quiz * @copyright 2012 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class attempts_report_options { /** @var string the report mode. */ public $mode; /** @var stdClass the settings for the quiz being reported on. */ public $quiz; /** @var stdClass the course module objects for the quiz being reported on. */ public $cm; /** @var stdClass the course settings for the course the quiz is in. */ public $course; /** * @var array form field name => corresponding quiz_attempt:: state constant. */ protected static $statefields = [ 'statenotstarted' => quiz_attempt::NOT_STARTED, 'stateinprogress' => quiz_attempt::IN_PROGRESS, 'stateoverdue' => quiz_attempt::OVERDUE, 'statesubmitted' => quiz_attempt::SUBMITTED, 'statefinished' => quiz_attempt::FINISHED, 'stateabandoned' => quiz_attempt::ABANDONED, ]; /** * @var string attempts_report::ALL_WITH, attempts_report::ENROLLED_WITH, * attempts_report::ENROLLED_WITHOUT or attempts_report::ENROLLED_ALL */ public $attempts = attempts_report::ENROLLED_WITH; /** @var int the currently selected group. 0 if no group is selected. */ public $group = 0; /** * @var array|null of quiz_attempt::IN_PROGRESS, etc. constants. null means * no restriction. */ public $states = [ quiz_attempt::NOT_STARTED, quiz_attempt::IN_PROGRESS, quiz_attempt::OVERDUE, quiz_attempt::SUBMITTED, quiz_attempt::FINISHED, quiz_attempt::ABANDONED, ]; /** * @var bool whether to show all finished attmepts, or just the one that gave * the final grade for the user. */ public $onlygraded = false; /** @var int Number of attempts to show per page. */ public $pagesize = attempts_report::DEFAULT_PAGE_SIZE; /** @var string whether the data should be downloaded in some format, or '' to display it. */ public $download = ''; /** @var bool whether the current user has permission to see grades. */ public $usercanseegrades; /** @var bool whether the report table should have a column of checkboxes. */ public $checkboxcolumn = false; /** * Constructor. * * @param string $mode which report these options are for. * @param stdClass $quiz the settings for the quiz being reported on. * @param stdClass $cm the course module objects for the quiz being reported on. * @param stdClass $course the course settings for the coures this quiz is in. */ public function __construct($mode, $quiz, $cm, $course) { $this->mode = $mode; $this->quiz = $quiz; $this->cm = $cm; $this->course = $course; $this->usercanseegrades = quiz_report_should_show_grades($quiz, context_module::instance($cm->id)); } /** * Get the URL parameters required to show the report with these options. * @return array URL parameter name => value. */ protected function get_url_params() { $params = [ 'id' => $this->cm->id, 'mode' => $this->mode, 'attempts' => $this->attempts, 'onlygraded' => $this->onlygraded, ]; if ($this->states) { $params['states'] = implode('-', $this->states); } if (groups_get_activity_groupmode($this->cm, $this->course)) { $params['group'] = $this->group; } return $params; } /** * Get the URL to show the report with these options. * @return moodle_url the URL. */ public function get_url() { return new moodle_url('/mod/quiz/report.php', $this->get_url_params()); } /** * Process the data we get when the settings form is submitted. This includes * updating the fields of this class, and updating the user preferences * where appropriate. * @param stdClass $fromform The data from $mform->get_data() from the settings form. */ public function process_settings_from_form($fromform) { $this->setup_from_form_data($fromform); $this->resolve_dependencies(); $this->update_user_preferences(); } /** * Set up this preferences object using optional_param (using user_preferences * to set anything not specified by the params. */ public function process_settings_from_params() { $this->setup_from_user_preferences(); $this->setup_from_params(); $this->resolve_dependencies(); } /** * Get the current value of the settings to pass to the settings form. */ public function get_initial_form_data() { $toform = new stdClass(); $toform->attempts = $this->attempts; $toform->onlygraded = $this->onlygraded; $toform->pagesize = $this->pagesize; if ($this->states) { foreach (self::$statefields as $field => $state) { $toform->$field = in_array($state, $this->states); } } return $toform; } /** * Set the fields of this object from the form data. * @param stdClass $fromform The data from $mform->get_data() from the settings form. */ public function setup_from_form_data($fromform) { $this->attempts = $fromform->attempts; $this->group = groups_get_activity_group($this->cm, true); $this->onlygraded = !empty($fromform->onlygraded); $this->pagesize = $fromform->pagesize; $this->states = []; foreach (self::$statefields as $field => $state) { if (!empty($fromform->$field)) { $this->states[] = $state; } } } /** * Set the fields of this object from the URL parameters. */ public function setup_from_params() { $this->attempts = optional_param('attempts', $this->attempts, PARAM_ALPHAEXT); $this->group = groups_get_activity_group($this->cm, true); $this->onlygraded = optional_param('onlygraded', $this->onlygraded, PARAM_BOOL); $this->pagesize = optional_param('pagesize', $this->pagesize, PARAM_INT); $states = optional_param('states', '', PARAM_ALPHAEXT); if (!empty($states)) { $this->states = explode('-', $states); } $this->download = optional_param('download', $this->download, PARAM_ALPHA); } /** * Set the fields of this object from the user's preferences. * (For those settings that are backed by user-preferences). */ public function setup_from_user_preferences() { $this->pagesize = get_user_preferences('quiz_report_pagesize', $this->pagesize); } /** * Update the user preferences so they match the settings in this object. * (For those settings that are backed by user-preferences). */ public function update_user_preferences() { set_user_preference('quiz_report_pagesize', $this->pagesize); } /** * Check the settings, and remove any 'impossible' combinations. */ public function resolve_dependencies() { if ($this->group) { // Default for when a group is selected. if ($this->attempts === null || $this->attempts == attempts_report::ALL_WITH) { $this->attempts = attempts_report::ENROLLED_WITH; } } else if (!$this->group && $this->course->id == SITEID) { // Force report on front page to show all, unless a group is selected. $this->attempts = attempts_report::ALL_WITH; } else if (!in_array($this->attempts, [attempts_report::ALL_WITH, attempts_report::ENROLLED_WITH, attempts_report::ENROLLED_WITHOUT, attempts_report::ENROLLED_ALL])) { $this->attempts = attempts_report::ENROLLED_WITH; } $cleanstates = []; foreach (self::$statefields as $state) { if (in_array($state, $this->states)) { $cleanstates[] = $state; } } $this->states = $cleanstates; if (count($this->states) == count(self::$statefields)) { // If all states have been selected, then there is no constraint // required in the SQL, so clear the array. $this->states = null; } if (!quiz_report_can_filter_only_graded($this->quiz)) { // A grading mode like 'average' has been selected, so we cannot do // the show the attempt that gave the final grade thing. $this->onlygraded = false; } if ($this->attempts == attempts_report::ENROLLED_WITHOUT) { $this->states = null; $this->onlygraded = false; } if (!$this->is_showing_finished_attempts()) { $this->onlygraded = false; } if ($this->pagesize < 1) { $this->pagesize = attempts_report::DEFAULT_PAGE_SIZE; } } /** * Whether the options are such that finished attempts are being shown. * @return boolean */ protected function is_showing_finished_attempts() { return $this->states === null || in_array(quiz_attempt::FINISHED, $this->states); } } PK9B\>oTATA)classes/local/reports/attempts_report.phpnu[. namespace mod_quiz\local\reports; use coding_exception; use context_module; use mod_quiz\quiz_settings; use moodle_url; use stdClass; use table_sql; defined('MOODLE_INTERNAL') || die(); require_once($CFG->libdir.'/tablelib.php'); /** * Base class for quiz reports that are basically a table with one row for each attempt. * * @package mod_quiz * @copyright 2010 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ abstract class attempts_report extends report_base { /** @var int default page size for reports. */ const DEFAULT_PAGE_SIZE = 30; /** @var string constant used for the options, means all users with attempts. */ const ALL_WITH = 'all_with'; /** @var string constant used for the options, means only enrolled users with attempts. */ const ENROLLED_WITH = 'enrolled_with'; /** @var string constant used for the options, means only enrolled users without attempts. */ const ENROLLED_WITHOUT = 'enrolled_without'; /** @var string constant used for the options, means all enrolled users. */ const ENROLLED_ALL = 'enrolled_any'; /** @var string the mode this report is. */ protected $mode; /** @var context_module the quiz context. */ protected $context; /** @var attempts_report_options_form The settings form to use. */ protected $form; /** @var string SQL fragment for selecting the attempt that gave the final grade, * if applicable. */ protected $qmsubselect; /** @var boolean caches the results of {@see should_show_grades()}. */ protected $showgrades = null; /** @var quiz_settings|null quiz settings object. Set in {@see init()}. */ protected $quizobj = null; /** * Can be used in subclasses to cache this information, but it will only get set if you set it. * @example an example use in quiz_overview_report. * * @var bool */ protected $hasgroupstudents; /** * Initialise various aspects of this report. * * @param string $mode * @param string $formclass * @param stdClass $quiz * @param stdClass $cm * @param stdClass $course * @return array with four elements: * 0 => integer the current group id (0 for none). * 1 => \core\dml\sql_join Contains joins, wheres, params for all the students in this course. * 2 => \core\dml\sql_join Contains joins, wheres, params for all the students in the current group. * 3 => \core\dml\sql_join Contains joins, wheres, params for all the students to show in the report. * Will be the same as either element 1 or 2. */ public function init($mode, $formclass, $quiz, $cm, $course): array { $this->mode = $mode; $this->quizobj = new quiz_settings($quiz, $cm, $course); $this->context = $this->quizobj->get_context(); [$currentgroup, $studentsjoins, $groupstudentsjoins, $allowedjoins] = $this->get_students_joins( $cm, $course); $this->qmsubselect = quiz_report_qm_filter_select($quiz); $this->form = new $formclass($this->get_base_url(), ['quiz' => $quiz, 'currentgroup' => $currentgroup, 'context' => $this->context]); return [$currentgroup, $studentsjoins, $groupstudentsjoins, $allowedjoins]; } /** * Get the base URL for this report. * @return moodle_url the URL. */ protected function get_base_url() { return new moodle_url('/mod/quiz/report.php', ['id' => $this->context->instanceid, 'mode' => $this->mode]); } /** * Get sql fragments (joins) which can be used to build queries that * will select an appropriate set of students to show in the reports. * * @param stdClass $cm the course module. * @param stdClass $course the course settings. * @return array with four elements: * 0 => integer the current group id (0 for none). * 1 => \core\dml\sql_join Contains joins, wheres, params for all the students in this course. * 2 => \core\dml\sql_join Contains joins, wheres, params for all the students in the current group. * 3 => \core\dml\sql_join Contains joins, wheres, params for all the students to show in the report. * Will be the same as either element 1 or 2. */ protected function get_students_joins($cm, $course = null) { $currentgroup = $this->get_current_group($cm, $course, $this->context); $empty = new \core\dml\sql_join(); if ($currentgroup == self::NO_GROUPS_ALLOWED) { return [$currentgroup, $empty, $empty, $empty]; } $studentsjoins = get_enrolled_with_capabilities_join($this->context, '', ['mod/quiz:attempt', 'mod/quiz:reviewmyattempts']); if (empty($currentgroup)) { return [$currentgroup, $studentsjoins, $empty, $studentsjoins]; } // We have a currently selected group. $groupstudentsjoins = get_enrolled_with_capabilities_join($this->context, '', ['mod/quiz:attempt', 'mod/quiz:reviewmyattempts'], $currentgroup); return [$currentgroup, $studentsjoins, $groupstudentsjoins, $groupstudentsjoins]; } /** * Outputs the things you commonly want at the top of a quiz report. * * Calls through to {@see print_header_and_tabs()} and then * outputs the standard group selector, number of attempts summary, * and messages to cover common cases when the report can't be shown. * * @param stdClass $cm the course_module information. * @param stdClass $course the course settings. * @param stdClass $quiz the quiz settings. * @param attempts_report_options $options the current report settings. * @param int $currentgroup the current group. * @param bool $hasquestions whether there are any questions in the quiz. * @param bool $hasstudents whether there are any relevant students. */ protected function print_standard_header_and_messages($cm, $course, $quiz, $options, $currentgroup, $hasquestions, $hasstudents) { global $OUTPUT; $this->print_header_and_tabs($cm, $course, $quiz, $this->mode); if (groups_get_activity_groupmode($cm)) { // Groups are being used, so output the group selector if we are not downloading. groups_print_activity_menu($cm, $options->get_url()); } // Print information on the number of existing attempts. if ($strattemptnum = quiz_num_attempt_summary($quiz, $cm, true, $currentgroup)) { echo '
' . $strattemptnum . '
'; } if (!$hasquestions) { echo quiz_no_questions_message($quiz, $cm, $this->context); } else if ($currentgroup == self::NO_GROUPS_ALLOWED) { echo $OUTPUT->notification(get_string('notingroup')); } else if (!$hasstudents) { echo $OUTPUT->notification(get_string('nostudentsyet')); } else if ($currentgroup && !$this->hasgroupstudents) { echo $OUTPUT->notification(get_string('nostudentsingroup')); } } /** * Add all the user-related columns to the $columns and $headers arrays. * @param table_sql $table the table being constructed. * @param array $columns the list of columns. Added to. * @param array $headers the columns headings. Added to. */ protected function add_user_columns($table, &$columns, &$headers) { global $CFG; if (!$table->is_downloading() && $CFG->grade_report_showuserimage) { $columns[] = 'picture'; $headers[] = ''; } if (!$table->is_downloading()) { $columns[] = 'fullname'; $headers[] = get_string('name'); } else { $columns[] = 'lastname'; $headers[] = get_string('lastname'); $columns[] = 'firstname'; $headers[] = get_string('firstname'); } $extrafields = \core_user\fields::get_identity_fields($this->context); foreach ($extrafields as $field) { $columns[] = $field; $headers[] = \core_user\fields::get_display_name($field); } } /** * Set the display options for the user-related columns in the table. * @param table_sql $table the table being constructed. */ protected function configure_user_columns($table) { $table->column_suppress('picture'); $table->column_suppress('fullname'); $extrafields = \core_user\fields::get_identity_fields($this->context); foreach ($extrafields as $field) { $table->column_suppress($field); } $table->column_class('picture', 'picture'); $table->column_class('lastname', 'bold'); $table->column_class('firstname', 'bold'); $table->column_class('fullname', 'bold'); $table->column_sticky('fullname'); } /** * Add the state column to the $columns and $headers arrays. * @param array $columns the list of columns. Added to. * @param array $headers the columns headings. Added to. */ protected function add_state_column(&$columns, &$headers) { global $PAGE; $columns[] = 'state'; $headers[] = get_string('attemptstate', 'quiz'); $PAGE->requires->js_call_amd('mod_quiz/reopen_attempt_ui', 'init'); } /** * Add all the time-related columns to the $columns and $headers arrays. * @param array $columns the list of columns. Added to. * @param array $headers the columns headings. Added to. */ protected function add_time_columns(&$columns, &$headers) { $columns[] = 'timestart'; $headers[] = get_string('startedon', 'quiz'); $columns[] = 'timefinish'; $headers[] = get_string('timecompleted', 'quiz'); $columns[] = 'duration'; $headers[] = get_string('attemptduration', 'quiz'); } /** * Add a column for the overall quiz grade and the overall feedback, if applicable. * * @param stdClass $quiz the quiz settings. * @param bool $usercanseegrades whether the user is allowed to see grades for this quiz. * @param array $columns the list of columns. Added to. * @param array $headers the columns headings. Added to. * @param bool $includefeedback whether to include the feedbacktext columns */ protected function add_grade_columns($quiz, $usercanseegrades, &$columns, &$headers, $includefeedback = true) { if ($usercanseegrades) { $columns[] = 'sumgrades'; $headers[] = get_string('gradenoun') . '/' . quiz_format_grade($quiz, $quiz->grade); } if ($includefeedback && quiz_has_feedback($quiz)) { $columns[] = 'feedbacktext'; $headers[] = get_string('feedback', 'quiz'); } } /** * Add columns for any extra quiz grade items, if applicable. * * @param bool $usercanseegrades whether the user is allowed to see grades for this quiz. * @param array $columns the list of columns. Added to. * @param array $headers the columns headings. Added to. */ protected function add_grade_item_columns(bool $usercanseegrades, array &$columns, array &$headers) { if (!$usercanseegrades) { return; } $gradeitems = $this->quizobj->get_grade_calculator()->get_grade_items(); if (!$gradeitems) { return; } foreach ($gradeitems as $gradeitem) { $columns[] = 'marks' . $gradeitem->id; $headers[] = format_string($gradeitem->name) . '/' . quiz_format_grade($this->quizobj->get_quiz(), $gradeitem->maxmark); } } /** * Set up the table. * @param attempts_report_table $table the table being constructed. * @param array $columns the list of columns. * @param array $headers the columns headings. * @param moodle_url $reporturl the URL of this report. * @param attempts_report_options $options the display options. * @param bool $collapsible whether to allow columns in the report to be collapsed. */ protected function set_up_table_columns($table, $columns, $headers, $reporturl, attempts_report_options $options, $collapsible) { $table->set_quiz_setting($this->quizobj); $table->define_columns($columns); $table->define_headers($headers); $table->sortable(true, 'uniqueid'); $table->define_baseurl($options->get_url()); $this->configure_user_columns($table); $table->no_sorting('feedbacktext'); $table->column_class('sumgrades', 'bold'); $table->set_attribute('id', 'attempts'); $table->collapsible($collapsible); } /** * Process any submitted actions. * @param stdClass $quiz the quiz settings. * @param stdClass $cm the cm object for the quiz. * @param int $currentgroup the currently selected group. * @param \core\dml\sql_join $groupstudentsjoins (joins, wheres, params) the students in the current group. * @param \core\dml\sql_join $allowedjoins (joins, wheres, params) the users whose attempt this user is allowed to modify. * @param moodle_url $redirecturl where to redircet to after a successful action. */ protected function process_actions($quiz, $cm, $currentgroup, \core\dml\sql_join $groupstudentsjoins, \core\dml\sql_join $allowedjoins, $redirecturl) { if (empty($currentgroup) || $this->hasgroupstudents) { if (optional_param('delete', 0, PARAM_BOOL) && confirm_sesskey()) { if ($attemptids = optional_param_array('attemptid', [], PARAM_INT)) { require_capability('mod/quiz:deleteattempts', $this->context); $this->delete_selected_attempts($quiz, $cm, $attemptids, $allowedjoins); redirect($redirecturl); } } } } /** * Delete the quiz attempts * @param stdClass $quiz the quiz settings. Attempts that don't belong to * this quiz are not deleted. * @param stdClass $cm the course_module object. * @param array $attemptids the list of attempt ids to delete. * @param \core\dml\sql_join $allowedjoins (joins, wheres, params) This list of userids that are visible in the report. * Users can only delete attempts that they are allowed to see in the report. * Empty means all users. */ protected function delete_selected_attempts($quiz, $cm, $attemptids, \core\dml\sql_join $allowedjoins) { global $DB; foreach ($attemptids as $attemptid) { if (empty($allowedjoins->joins)) { $sql = "SELECT quiza.* FROM {quiz_attempts} quiza JOIN {user} u ON u.id = quiza.userid WHERE quiza.id = :attemptid"; } else { $sql = "SELECT quiza.* FROM {quiz_attempts} quiza JOIN {user} u ON u.id = quiza.userid {$allowedjoins->joins} WHERE {$allowedjoins->wheres} AND quiza.id = :attemptid"; } $params = $allowedjoins->params + ['attemptid' => $attemptid]; $attempt = $DB->get_record_sql($sql, $params, IGNORE_MULTIPLE); if (!$attempt || $attempt->quiz != $quiz->id || $attempt->preview != 0) { // Ensure the attempt exists, belongs to this quiz and belongs to // a student included in the report. If not skip. continue; } // Set the course module id before calling quiz_delete_attempt(). $quiz->cmid = $cm->id; quiz_delete_attempt($attempt, $quiz); } } } PK9B\ܖ*?? classes/local/override_cache.phpnu[. namespace mod_quiz\local; /** * Cache manager for quiz overrides * * Override cache data is set via its data source, {@see \mod_quiz\cache\overrides} * @package mod_quiz * @copyright 2024 Matthew Hilton * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class override_cache { /** @var string invalidation event used to purge data when reset_userdata is called, {@see \cache_helper::purge_by_event()} **/ public const INVALIDATION_USERDATARESET = 'userdatareset'; /** * Create override_cache object and link to quiz * * @param int $quizid The quiz to link this cache to */ public function __construct( /** @var int $quizid ID of quiz cache is being operated on **/ protected readonly int $quizid ) { } /** * Returns the override cache * * @return \cache */ protected function get_cache(): \cache { return \cache::make('mod_quiz', 'overrides'); } /** * Returns group cache key * * @param int $groupid * @return string the group cache key */ protected function get_group_cache_key(int $groupid): string { return "{$this->quizid}_g_{$groupid}"; } /** * Returns user cache key * * @param int $userid * @return string the user cache key */ protected function get_user_cache_key(int $userid): string { return "{$this->quizid}_u_{$userid}"; } /** * Returns the override value in the cache for the given group * * @param int $groupid group to get cached override data for * @return ?\stdClass override value in the cache for the given group, or null if there is none. */ public function get_cached_group_override(int $groupid): ?\stdClass { $raw = $this->get_cache()->get($this->get_group_cache_key($groupid)); return empty($raw) || !is_object($raw) ? null : (object) $raw; } /** * Returns the override value in the cache for the given user * * @param int $userid user to get cached override data for * @return ?\stdClass the override value in the cache for the given user, or null if there is none. */ public function get_cached_user_override(int $userid): ?\stdClass { $raw = $this->get_cache()->get($this->get_user_cache_key($userid)); return empty($raw) || !is_object($raw) ? null : (object) $raw; } /** * Deletes the cached override data for a given group * * @param int $groupid group to delete data for */ public function clear_for_group(int $groupid): void { $this->get_cache()->delete($this->get_group_cache_key($groupid)); } /** * Deletes the cached override data for the given user * * @param int $userid user to delete data for */ public function clear_for_user(int $userid): void { $this->get_cache()->delete($this->get_user_cache_key($userid)); } /** * Clears the cache for the given user and/or group. * * @param ?int $userid user to delete data for, or null. * @param ?int $groupid group to delete data for, or null. */ public function clear_for(?int $userid = null, ?int $groupid = null): void { if (!empty($userid)) { $this->clear_for_user($userid); } if (!empty($groupid)) { $this->clear_for_group($groupid); } } } PK9B\^^"classes/local/override_manager.phpnu[. namespace mod_quiz\local; use mod_quiz\event\group_override_created; use mod_quiz\event\group_override_deleted; use mod_quiz\event\group_override_updated; use mod_quiz\event\user_override_created; use mod_quiz\event\user_override_deleted; use mod_quiz\event\user_override_updated; use mod_quiz\quiz_settings; /** * Manager class for quiz overrides * * @package mod_quiz * @copyright 2024 Matthew Hilton * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class override_manager { /** @var array quiz setting keys that can be overwritten **/ private const OVERRIDEABLE_QUIZ_SETTINGS = ['timeopen', 'timeclose', 'timelimit', 'attempts', 'password']; /** * Create override manager * * @param \stdClass $quiz The quiz to link the manager to. * @param \context_module $context Context being operated in */ public function __construct( /** @var \stdClass The quiz linked to this manager instance **/ protected readonly \stdClass $quiz, /** @var \context_module The context being operated in **/ public readonly \context_module $context ) { global $CFG; // Required for quiz_* methods. require_once($CFG->dirroot . '/mod/quiz/locallib.php'); // Sanity check that the context matches the quiz. if (empty($quiz->cmid) || $quiz->cmid != $context->instanceid) { throw new \coding_exception("Given context does not match the quiz object"); } } /** * Returns all overrides for the linked quiz. * * @return array of quiz_override records */ public function get_all_overrides(): array { global $DB; return $DB->get_records('quiz_overrides', ['quiz' => $this->quiz->id]); } /** * Validates the data, usually from a moodleform or a webservice call. * If it contains an 'id' property, additional validation is performed against the existing record. * * @param array $formdata data from moodleform or webservice call. * @return array array where the keys are error elements, and the values are lists of errors for each element. */ public function validate_data(array $formdata): array { global $DB; // Because this can be called directly (e.g. via edit_override_form) // and not just through save_override, we must ensure the data // is parsed in the same way. $formdata = $this->parse_formdata($formdata); $formdata = (object) $formdata; $errors = []; // Ensure at least one of the overrideable settings is set. $keysthatareset = array_map(function ($key) use ($formdata) { return isset($formdata->$key) && !is_null($formdata->$key); }, self::OVERRIDEABLE_QUIZ_SETTINGS); if (!in_array(true, $keysthatareset)) { $errors['general'][] = new \lang_string('nooverridedata', 'quiz'); } // Ensure quiz is a valid quiz. if (empty($formdata->quiz) || empty(get_coursemodule_from_instance('quiz', $formdata->quiz))) { $errors['quiz'][] = new \lang_string('overrideinvalidquiz', 'quiz'); } // Ensure either userid or groupid is set. if (empty($formdata->userid) && empty($formdata->groupid)) { $errors['general'][] = new \lang_string('overridemustsetuserorgroup', 'quiz'); } // Ensure not both userid and groupid are set. if (!empty($formdata->userid) && !empty($formdata->groupid)) { $errors['general'][] = new \lang_string('overridecannotsetbothgroupanduser', 'quiz'); } // If group is set, ensure it is a real group. if (!empty($formdata->groupid) && empty(groups_get_group($formdata->groupid))) { $errors['groupid'][] = new \lang_string('overrideinvalidgroup', 'quiz'); } // If user is set, ensure it is a valid user. if (!empty($formdata->userid) && !\core_user::is_real_user($formdata->userid, true)) { $errors['userid'][] = new \lang_string('overrideinvaliduser', 'quiz'); } // Ensure timeclose is later than timeopen, if both are set. if (!empty($formdata->timeclose) && !empty($formdata->timeopen) && $formdata->timeclose <= $formdata->timeopen) { $errors['timeclose'][] = new \lang_string('closebeforeopen', 'quiz'); } // Ensure attempts is a integer greater than or equal to 0 (0 is unlimited attempts). if (isset($formdata->attempts) && ((int) $formdata->attempts < 0)) { $errors['attempts'][] = new \lang_string('overrideinvalidattempts', 'quiz'); } // Ensure timelimit is greather than zero. if (!empty($formdata->timelimit) && $formdata->timelimit <= 0) { $errors['timelimit'][] = new \lang_string('overrideinvalidtimelimit', 'quiz'); } // Ensure other records do not exist with the same group or user. if (!empty($formdata->quiz) && (!empty($formdata->userid) || !empty($formdata->groupid))) { $existingrecordparams = ['quiz' => $formdata->quiz, 'groupid' => $formdata->groupid ?? null, 'userid' => $formdata->userid ?? null, ]; $records = $DB->get_records('quiz_overrides', $existingrecordparams, '', 'id'); // Ignore self if updating. if (!empty($formdata->id)) { unset($records[$formdata->id]); } // If count is not zero, it means existing records exist already for this user/group. if (!empty($records)) { $errors['general'][] = new \lang_string('overridemultiplerecordsexist', 'quiz'); } } // If is existing record, validate it against the existing record. if (!empty($formdata->id)) { $existingrecorderrors = self::validate_against_existing_record($formdata->id, $formdata); $errors = array_merge($errors, $existingrecorderrors); } // Implode each value (array of error strings) into a single error string. foreach ($errors as $key => $value) { $errors[$key] = implode(",", $value); } return $errors; } /** * Returns the existing quiz override record with the given ID or null if does not exist. * * @param int $id existing quiz override id * @return ?\stdClass record, if exists */ private static function get_existing(int $id): ?\stdClass { global $DB; return $DB->get_record('quiz_overrides', ['id' => $id]) ?: null; } /** * Validates the formdata against an existing record. * * @param int $existingid id of existing quiz override record * @param \stdClass $formdata formdata, usually from moodleform or webservice call. * @return array array where the keys are error elements, and the values are lists of errors for each element. */ private static function validate_against_existing_record(int $existingid, \stdClass $formdata): array { $existingrecord = self::get_existing($existingid); $errors = []; // Existing record must exist. if (empty($existingrecord)) { $errors['general'][] = new \lang_string('overrideinvalidexistingid', 'quiz'); } // Group value must match existing record if it is set in the formdata. if (!empty($existingrecord) && !empty($formdata->groupid) && $existingrecord->groupid != $formdata->groupid) { $errors['groupid'][] = new \lang_string('overridecannotchange', 'quiz'); } // User value must match existing record if it is set in the formdata. if (!empty($existingrecord) && !empty($formdata->userid) && $existingrecord->userid != $formdata->userid) { $errors['userid'][] = new \lang_string('overridecannotchange', 'quiz'); } return $errors; } /** * Parses the formdata by finding only the OVERRIDEABLE_QUIZ_SETTINGS, * clearing any values that match the existing quiz, and re-adds the user or group id. * * @param array $formdata data usually from moodleform or webservice call. * @return array array containing parsed formdata, with keys as the properties and values as the values. * Any values set the same as the existing quiz are set to null. */ public function parse_formdata(array $formdata): array { // Get the data from the form that we want to update. $settings = array_intersect_key($formdata, array_flip(self::OVERRIDEABLE_QUIZ_SETTINGS)); // Remove values that are the same as currently in the quiz. $settings = $this->clear_unused_values($settings); // Add the user / group back as applicable. $userorgroupdata = array_intersect_key($formdata, array_flip(['userid', 'groupid', 'quiz', 'id'])); return array_merge($settings, $userorgroupdata); } /** * Saves the given override. If an id is given, it updates, otherwise it creates a new one. * Note, capabilities are not checked, {@see require_manage_capability()} * * @param array $formdata data usually from moodleform or webservice call. * @return int updated/inserted record id */ public function save_override(array $formdata): int { global $DB; // Extract only the necessary data. $datatoset = $this->parse_formdata($formdata); $datatoset['quiz'] = $this->quiz->id; // Validate the data is OK. $errors = $this->validate_data($datatoset); if (!empty($errors)) { $errorstr = implode(',', $errors); throw new \invalid_parameter_exception($errorstr); } // Insert or update. $id = $datatoset['id'] ?? 0; if (!empty($id)) { $DB->update_record('quiz_overrides', $datatoset); } else { $id = $DB->insert_record('quiz_overrides', $datatoset); } $userid = $datatoset['userid'] ?? null; $groupid = $datatoset['groupid'] ?? null; // Clear the cache. $cache = new override_cache($this->quiz->id); $cache->clear_for($userid, $groupid); // Trigger moodle events. if (empty($formdata['id'])) { $this->fire_created_event($id, $userid, $groupid); } else { $this->fire_updated_event($id, $userid, $groupid); } // Update open events. quiz_update_open_attempts(['quizid' => $this->quiz->id]); // Update calendar events. $isgroup = !empty($datatoset['groupid']); if ($isgroup) { // If is group, must update the entire quiz calendar events. quiz_update_events($this->quiz); } else { // If is just a user, can update only their calendar event. quiz_update_events($this->quiz, (object) $datatoset); } return $id; } /** * Deletes all the overrides for the linked quiz * * @param bool $shouldlog If true, will log a override_deleted event */ public function delete_all_overrides(bool $shouldlog = true): void { global $DB; $overrides = $DB->get_records('quiz_overrides', ['quiz' => $this->quiz->id], '', 'id,userid,groupid'); $this->delete_overrides($overrides, $shouldlog); } /** * Deletes overrides given just their ID. * Note, the given IDs must exist and user must have access to them otherwise an exception will be thrown. * Also note, capabilities are not checked, {@see require_manage_capability()} * * @param array $ids IDs of overrides to delete * @param bool $shouldlog If true, will log a override_deleted event */ public function delete_overrides_by_id(array $ids, bool $shouldlog = true): void { global $DB; $quizsettings = quiz_settings::create($this->quiz->id); // Filter for those overrides user can access. [$sql, $params] = self::get_override_in_sql($this->quiz->id, $ids); $records = array_filter( $DB->get_records_select('quiz_overrides', $sql, $params, '', 'id,userid,groupid'), fn(\stdClass $override) => $this->can_view_override( $override, $quizsettings->get_course(), $quizsettings->get_cm(), ), ); // Ensure all the given ids exist, so the user is aware if they give a dodgy id. $missingids = array_diff($ids, array_keys($records)); if (!empty($missingids)) { throw new \invalid_parameter_exception(get_string('overridemissingdelete', 'quiz', implode(',', $missingids))); } $this->delete_overrides($records, $shouldlog); } /** * Builds sql and parameters to find overrides in quiz with the given ids * * @param int $quizid id of quiz * @param array $ids array of quiz override ids * @return array sql and params */ private static function get_override_in_sql(int $quizid, array $ids): array { global $DB; [$insql, $inparams] = $DB->get_in_or_equal($ids, SQL_PARAMS_NAMED); $params = array_merge($inparams, ['quizid' => $quizid]); $sql = 'id ' . $insql . ' AND quiz = :quizid'; return [$sql, $params]; } /** * Deletes the given overrides in the quiz linked to the override manager. * Note - capabilities are not checked, {@see require_manage_capability()} * * @param array $overrides override to delete. Must specify an id, quizid, and either a userid or groupid. * @param bool $shouldlog If true, will log a override_deleted event */ public function delete_overrides(array $overrides, bool $shouldlog = true): void { global $DB; foreach ($overrides as $override) { if (empty($override->id)) { throw new \coding_exception("All overrides must specify an ID"); } // Sanity check that user xor group is specified. // User or group is required to clear the cache. self::ensure_userid_xor_groupid_set($override->userid ?? null, $override->groupid ?? null); } if (empty($overrides)) { // Exit early, since delete select requires at least 1 record. return; } // Match id and quiz. [$sql, $params] = self::get_override_in_sql($this->quiz->id, array_column($overrides, 'id')); $DB->delete_records_select('quiz_overrides', $sql, $params); $cache = new override_cache($this->quiz->id); // Perform other cleanup. foreach ($overrides as $override) { $userid = $override->userid ?? null; $groupid = $override->groupid ?? null; $cache->clear_for($userid, $groupid); $this->delete_override_events($userid, $groupid); if ($shouldlog) { $this->fire_deleted_event($override->id, $userid, $groupid); } } } /** * Ensures either userid or groupid is set, but not both. * If neither or both are set, a coding exception is thrown. * * @param ?int $userid user for the record, or null * @param ?int $groupid group for the record, or null */ private static function ensure_userid_xor_groupid_set(?int $userid = null, ?int $groupid = null): void { $groupset = !empty($groupid); $userset = !empty($userid); // If either set, but not both (xor). $xorset = $groupset ^ $userset; if (!$xorset) { throw new \coding_exception("Either userid or groupid must be specified, but not both."); } } /** * Deletes the events associated with the override. * * @param ?int $userid or null if groupid is specified * @param ?int $groupid or null if the userid is specified */ private function delete_override_events(?int $userid = null, ?int $groupid = null): void { global $DB; // Sanity check. self::ensure_userid_xor_groupid_set($userid, $groupid); $eventssearchparams = ['modulename' => 'quiz', 'instance' => $this->quiz->id]; if (!empty($userid)) { $eventssearchparams['userid'] = $userid; } if (!empty($groupid)) { $eventssearchparams['groupid'] = $groupid; } $events = $DB->get_records('event', $eventssearchparams); foreach ($events as $event) { $eventold = \calendar_event::load($event); $eventold->delete(); } } /** * Requires the user has the override management capability */ public function require_manage_capability(): void { require_capability('mod/quiz:manageoverrides', $this->context); } /** * Requires the user has the override viewing capability */ public function require_read_capability(): void { // If user can manage, they can also view. // It would not make sense to be able to create and edit overrides without being able to view them. if (!has_any_capability(['mod/quiz:viewoverrides', 'mod/quiz:manageoverrides'], $this->context)) { throw new \required_capability_exception($this->context, 'mod/quiz:viewoverrides', 'nopermissions', ''); } } /** * Determine whether user can view a given override record * * @param \stdClass $override * @param \stdClass $course * @param \cm_info $cm * @return bool */ public function can_view_override(\stdClass $override, \stdClass $course, \cm_info $cm): bool { if ($override->groupid) { return groups_group_visible($override->groupid, $course, $cm); } else { return groups_user_groups_visible($course, $override->userid, $cm); } } /** * Builds common event data * * @param int $id override id * @return array of data to add as parameters to an event. */ private function get_base_event_params(int $id): array { return [ 'context' => $this->context, 'other' => [ 'quizid' => $this->quiz->id, ], 'objectid' => $id, ]; } /** * Log that a given override was deleted * * @param int $id of quiz override that was just deleted * @param ?int $userid user attached to override record, or null * @param ?int $groupid group attached to override record, or null */ private function fire_deleted_event(int $id, ?int $userid = null, ?int $groupid = null): void { // Sanity check. self::ensure_userid_xor_groupid_set($userid, $groupid); $params = $this->get_base_event_params($id); $params['objectid'] = $id; if (!empty($userid)) { $params['relateduserid'] = $userid; user_override_deleted::create($params)->trigger(); } if (!empty($groupid)) { $params['other']['groupid'] = $groupid; group_override_deleted::create($params)->trigger(); } } /** * Log that a given override was created * * @param int $id of quiz override that was just created * @param ?int $userid user attached to override record, or null * @param ?int $groupid group attached to override record, or null */ private function fire_created_event(int $id, ?int $userid = null, ?int $groupid = null): void { // Sanity check. self::ensure_userid_xor_groupid_set($userid, $groupid); $params = $this->get_base_event_params($id); if (!empty($userid)) { $params['relateduserid'] = $userid; user_override_created::create($params)->trigger(); } if (!empty($groupid)) { $params['other']['groupid'] = $groupid; group_override_created::create($params)->trigger(); } } /** * Log that a given override was updated * * @param int $id of quiz override that was just updated * @param ?int $userid user attached to override record, or null * @param ?int $groupid group attached to override record, or null */ private function fire_updated_event(int $id, ?int $userid = null, ?int $groupid = null): void { // Sanity check. self::ensure_userid_xor_groupid_set($userid, $groupid); $params = $this->get_base_event_params($id); if (!empty($userid)) { $params['relateduserid'] = $userid; user_override_updated::create($params)->trigger(); } if (!empty($groupid)) { $params['other']['groupid'] = $groupid; group_override_updated::create($params)->trigger(); } } /** * Clears any overrideable settings in the formdata, where the value matches what is already in the quiz * If they match, the data is set to null. * * @param array $formdata data usually from moodleform or webservice call. * @return array formdata with same values cleared */ private function clear_unused_values(array $formdata): array { foreach (self::OVERRIDEABLE_QUIZ_SETTINGS as $key) { // If the formdata is the same as the current quiz object data, clear it. if (isset($formdata[$key]) && $formdata[$key] == $this->quiz->$key) { $formdata[$key] = null; } // Ensure these keys always are set (even if null). $formdata[$key] = $formdata[$key] ?? null; // If the formdata is empty, set it to null. // This avoids putting 0, false, or '' into the DB since the override logic expects null. // Attempts is the exception, it can have a integer value of '0', so we use is_numeric instead. if ($key != 'attempts' && empty($formdata[$key])) { $formdata[$key] = null; } if ($key == 'attempts' && !is_numeric($formdata[$key])) { $formdata[$key] = null; } } return $formdata; } /** * Deletes orphaned group overrides in a given course. * Note - permissions are not checked and events are not logged for performance reasons. * * @param int $courseid ID of course to delete orphaned group overrides in * @return array array of quizzes that had orphaned group overrides. */ public static function delete_orphaned_group_overrides_in_course(int $courseid): array { global $DB; // It would be nice if we got the groupid that was deleted. // Instead, we just update all quizzes with orphaned group overrides. $sql = "SELECT o.id, o.quiz, o.groupid FROM {quiz_overrides} o JOIN {quiz} quiz ON quiz.id = o.quiz LEFT JOIN {groups} grp ON grp.id = o.groupid WHERE quiz.course = :courseid AND o.groupid IS NOT NULL AND grp.id IS NULL"; $params = ['courseid' => $courseid]; $records = $DB->get_records_sql($sql, $params); $DB->delete_records_list('quiz_overrides', 'id', array_keys($records)); // Purge cache for each record. foreach ($records as $record) { $cache = new override_cache($record->quiz); $cache->clear_for_group($record->groupid); } return array_unique(array_column($records, 'quiz')); } } PK9B\2, classes/group_observers.phpnu[. /** * Group observers. * * @package mod_quiz * @copyright 2013 Frédéric Massart * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace mod_quiz; defined('MOODLE_INTERNAL') || die(); require_once($CFG->dirroot . '/mod/quiz/locallib.php'); /** * Group observers class. * * @package mod_quiz * @copyright 2013 Frédéric Massart * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class group_observers { /** * Flag whether a course reset is in progress or not. * * @var int The course ID. */ protected static $resetinprogress = false; /** * A course reset has started. * * @param \core\event\base $event The event. * @return void */ public static function course_reset_started($event) { self::$resetinprogress = $event->courseid; } /** * A course reset has ended. * * @param \core\event\base $event The event. * @return void */ public static function course_reset_ended($event) { if (!empty(self::$resetinprogress)) { if (!empty($event->other['reset_options']['reset_groups_remove'])) { quiz_process_group_deleted_in_course($event->courseid); } if (!empty($event->other['reset_options']['reset_groups_members'])) { quiz_update_open_attempts(['courseid' => $event->courseid]); } } self::$resetinprogress = null; } /** * A group was deleted. * * @param \core\event\base $event The event. * @return void */ public static function group_deleted($event) { if (!empty(self::$resetinprogress)) { // We will take care of that once the course reset ends. return; } quiz_process_group_deleted_in_course($event->courseid); } /** * A group member was removed. * * @param \core\event\base $event The event. * @return void */ public static function group_member_added($event) { quiz_update_open_attempts(['userid' => $event->relateduserid, 'groupid' => $event->objectid]); } /** * A group member was deleted. * * @param \core\event\base $event The event. * @return void */ public static function group_member_removed($event) { if (!empty(self::$resetinprogress)) { // We will take care of that once the course reset ends. return; } quiz_update_open_attempts(['userid' => $event->relateduserid, 'groupid' => $event->objectid]); } } PK9B\HGYYclasses/quiz_settings.phpnu[. namespace mod_quiz; use cm_info; use coding_exception; use context; use context_module; use core_question\local\bank\question_version_status; use mod_quiz\question\bank\qbank_helper; use mod_quiz\question\display_options; use moodle_exception; use moodle_url; use question_bank; use stdClass; /** * A class encapsulating the settings for a quiz. * * When this class is initialised, it may have the settings adjusted to account * for the overrides for a particular user. See the create methods. * * Initially, it only loads a minimal amount of information about each question - loading * extra information only when necessary or when asked. The class tracks which questions * are loaded. * * @package mod_quiz * @copyright 2008 Tim Hunt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class quiz_settings { /** @var stdClass the course settings from the database. */ protected $course; /** @var cm_info the course_module settings from the database. */ protected $cm; /** @var stdClass the quiz settings from the database. */ protected $quiz; /** @var context the quiz context. */ protected $context; /** * @var stdClass[] of questions augmented with slot information. For non-random * questions, the array key is question id. For random quesions it is 's' . $slotid. * probalby best to use ->questionid field of the object instead. */ protected $questions = null; /** @var stdClass[] of quiz_section rows. */ protected $sections = null; /** @var access_manager the access manager for this quiz. */ protected $accessmanager = null; /** @var bool whether the current user has capability mod/quiz:preview. */ protected $ispreviewuser = null; /** @var grade_calculator|null grade calculator for this quiz. */ protected ?grade_calculator $gradecalculator = null; // Constructor =============================================================. /** * Constructor, assuming we already have the necessary data loaded. * * @param stdClass $quiz the row from the quiz table. * @param stdClass $cm the course_module object for this quiz. * @param stdClass $course the row from the course table for the course we belong to. * @param bool $getcontext intended for testing - stops the constructor getting the context. */ public function __construct($quiz, $cm, $course, $getcontext = true) { $this->quiz = $quiz; $this->cm = $cm; $this->quiz->cmid = $this->cm->id; $this->course = $course; if ($getcontext && !empty($cm->id)) { $this->context = context_module::instance($cm->id); } } /** * Helper used by the other factory methods. * * @param stdClass $quiz * @param cm_info $cm * @param stdClass $course * @param int|null $userid the the userid (optional). If passed, relevant overrides are applied. * @return quiz_settings the new quiz settings object. */ protected static function create_helper(stdClass $quiz, cm_info $cm, stdClass $course, ?int $userid): self { // Update quiz with override information. if ($userid) { $quiz = quiz_update_effective_access($quiz, $userid); } return new quiz_settings($quiz, $cm, $course); } /** * Static function to create a new quiz settings object from a quiz id, for a specific user. * * @param int $quizid the quiz id. * @param int|null $userid the the userid (optional). If passed, relevant overrides are applied. * @return quiz_settings the new quiz settings object. */ public static function create(int $quizid, ?int $userid = null): self { $quiz = access_manager::load_quiz_and_settings($quizid); [$course, $cm] = get_course_and_cm_from_instance($quiz, 'quiz'); return self::create_helper($quiz, $cm, $course, $userid); } /** * Static function to create a new quiz settings object from a cmid, for a specific user. * * @param int $cmid the course-module id. * @param int|null $userid the the userid (optional). If passed, relevant overrides are applied. * @return quiz_settings the new quiz settings object. */ public static function create_for_cmid(int $cmid, ?int $userid = null): self { [$course, $cm] = get_course_and_cm_from_cmid($cmid, 'quiz'); $quiz = access_manager::load_quiz_and_settings($cm->instance); return self::create_helper($quiz, $cm, $course, $userid); } /** * Create a {@see quiz_attempt} for an attempt at this quiz. * * @param stdClass $attemptdata row from the quiz_attempts table. * @return quiz_attempt the new quiz_attempt object. */ public function create_attempt_object($attemptdata) { return new quiz_attempt($attemptdata, $this->quiz, $this->cm, $this->course); } // Functions for loading more data =========================================. /** * Load just basic information about all the questions in this quiz. */ public function preload_questions() { $slots = qbank_helper::get_question_structure($this->quiz->id, $this->context); $this->questions = []; foreach ($slots as $slot) { $this->questions[$slot->questionid] = $slot; } } /** * Fully load some or all of the questions for this quiz. You must call * {@see preload_questions()} first. * * @param array|null $deprecated no longer supported (it was not used). */ public function load_questions($deprecated = null) { if ($deprecated !== null) { debugging('The argument to quiz::load_questions is no longer supported. ' . 'All questions are always loaded.', DEBUG_DEVELOPER); } if ($this->questions === null) { throw new coding_exception('You must call preload_questions before calling load_questions.'); } $questionstoprocess = []; foreach ($this->questions as $question) { if (is_number($question->questionid)) { $question->id = $question->questionid; $questionstoprocess[$question->questionid] = $question; } } get_question_options($questionstoprocess); } /** * Get an instance of the {@see \mod_quiz\structure} class for this quiz. * * @return structure describes the questions in the quiz. */ public function get_structure() { return structure::create_for_quiz($this); } // Simple getters ==========================================================. /** * Get the id of the course this quiz belongs to. * * @return int the course id. */ public function get_courseid() { return $this->course->id; } /** * Get the course settings object that this quiz belongs to. * * @return stdClass the row of the course table. */ public function get_course() { return $this->course; } /** * Get this quiz's id (in the quiz table). * * @return int the quiz id. */ public function get_quizid() { return $this->quiz->id; } /** * Get the quiz settings object. * * @return stdClass the row of the quiz table. */ public function get_quiz() { return $this->quiz; } /** * Get the quiz name. * * @return string the name of this quiz. */ public function get_quiz_name() { return $this->quiz->name; } /** * Get the navigation method in use. * * @return int QUIZ_NAVMETHOD_FREE or QUIZ_NAVMETHOD_SEQ. */ public function get_navigation_method() { return $this->quiz->navmethod; } /** * How many attepts is the user allowed at this quiz? * * @return int the number of attempts allowed at this quiz (0 = infinite). */ public function get_num_attempts_allowed() { return $this->quiz->attempts; } /** * Get the course-module id for this quiz. * * @return int the course_module id. */ public function get_cmid() { return $this->cm->id; } /** * Get the course-module object for this quiz. * * @return cm_info the course_module object. */ public function get_cm() { return $this->cm; } /** * Get the quiz context. * * @return context_module the module context for this quiz. */ public function get_context() { return $this->context; } /** * Is the current user is someone who previews the quiz, rather than attempting it? * * @return bool true user is a preview user. False, if they can do real attempts. */ public function is_preview_user() { if (is_null($this->ispreviewuser)) { $this->ispreviewuser = has_capability('mod/quiz:preview', $this->context); } return $this->ispreviewuser; } /** * Checks user enrollment in the current course. * * @param int $userid the id of the user to check. * @return bool whether the user is enrolled. */ public function is_participant($userid) { return is_enrolled($this->get_context(), $userid, 'mod/quiz:attempt', $this->show_only_active_users()); } /** * Check is only active users in course should be shown. * * @return bool true if only active users should be shown. */ public function show_only_active_users() { return !has_capability('moodle/course:viewsuspendedusers', $this->get_context()); } /** * Have any questions been added to this quiz yet? * * @return bool whether any questions have been added to this quiz. */ public function has_questions() { if ($this->questions === null) { $this->preload_questions(); } return !empty($this->questions); } /** * Get a particular question in this quiz, by its id. * * @param int $id the question id. * @return stdClass the question object with that id. */ public function get_question($id) { return $this->questions[$id]; } /** * Get some of the question in this quiz. * * @param array|null $questionids question ids of the questions to load. null for all. * @param bool $requirequestionfullyloaded Whether to require that a particular question is fully loaded. * @return stdClass[] the question data objects. */ public function get_questions(?array $questionids = null, bool $requirequestionfullyloaded = true) { if (is_null($questionids)) { $questionids = array_keys($this->questions); } $questions = []; foreach ($questionids as $id) { if (!array_key_exists($id, $this->questions)) { throw new moodle_exception('cannotstartmissingquestion', 'quiz', $this->view_url()); } $questions[$id] = $this->questions[$id]; if ($requirequestionfullyloaded) { $this->ensure_question_loaded($id); } } return $questions; } /** * Get all the sections in this quiz. * * @return array 0, 1, 2, ... => quiz_sections row from the database. */ public function get_sections() { global $DB; if ($this->sections === null) { $this->sections = array_values($DB->get_records('quiz_sections', ['quizid' => $this->get_quizid()], 'firstslot')); } return $this->sections; } /** * Return access_manager and instance of the access_manager class * for this quiz at this time. * * @param int $timenow the current time as a unix timestamp. * @return access_manager an instance of the access_manager class * for this quiz at this time. */ public function get_access_manager($timenow) { if (is_null($this->accessmanager)) { $this->accessmanager = new access_manager($this, $timenow, has_capability('mod/quiz:ignoretimelimits', $this->context, null, false)); } return $this->accessmanager; } /** * Return the grade_calculator object for this quiz. * * @return grade_calculator */ public function get_grade_calculator(): grade_calculator { if ($this->gradecalculator === null) { $this->gradecalculator = grade_calculator::create($this); } return $this->gradecalculator; } /** * Wrapper round the has_capability funciton that automatically passes in the quiz context. * * @param string $capability the name of the capability to check. For example mod/quiz:view. * @param int|null $userid A user id. By default (null) checks the permissions of the current user. * @param bool $doanything If false, ignore effect of admin role assignment. * @return boolean true if the user has this capability. Otherwise false. */ public function has_capability($capability, $userid = null, $doanything = true) { return has_capability($capability, $this->context, $userid, $doanything); } /** * Wrapper round the require_capability function that automatically passes in the quiz context. * * @param string $capability the name of the capability to check. For example mod/quiz:view. * @param int|null $userid A user id. By default (null) checks the permissions of the current user. * @param bool $doanything If false, ignore effect of admin role assignment. */ public function require_capability($capability, $userid = null, $doanything = true) { require_capability($capability, $this->context, $userid, $doanything); } // URLs related to this attempt ============================================. /** * Get the URL of this quiz's view.php page. * * @return moodle_url the URL of this quiz's view page. */ public function view_url() { return new moodle_url('/mod/quiz/view.php', ['id' => $this->cm->id]); } /** * Get the URL of this quiz's edit questions page. * * @return moodle_url the URL of this quiz's edit page. */ public function edit_url() { return new moodle_url('/mod/quiz/edit.php', ['cmid' => $this->cm->id]); } /** * Get the URL of a particular page within an attempt. * * @param int $attemptid the id of an attempt. * @param int $page optional page number to go to in the attempt. * @return moodle_url the URL of that attempt. */ public function attempt_url($attemptid, $page = 0) { $params = ['attempt' => $attemptid, 'cmid' => $this->get_cmid()]; if ($page) { $params['page'] = $page; } return new moodle_url('/mod/quiz/attempt.php', $params); } /** * Get the URL to start/continue an attempt. * * @param int $page page in the attempt to start on (optional). * @return moodle_url the URL of this quiz's edit page. Needs to be POSTed to with a cmid parameter. */ public function start_attempt_url($page = 0) { $params = ['cmid' => $this->cm->id, 'sesskey' => sesskey()]; if ($page) { $params['page'] = $page; } return new moodle_url('/mod/quiz/startattempt.php', $params); } /** * Get the URL to review a particular quiz attempt. * * @param int $attemptid the id of an attempt. * @return string the URL of the review of that attempt. */ public function review_url($attemptid) { return new moodle_url('/mod/quiz/review.php', ['attempt' => $attemptid, 'cmid' => $this->get_cmid()]); } /** * Get the URL for the summary page for a particular attempt. * * @param int $attemptid the id of an attempt. * @return string the URL of the review of that attempt. */ public function summary_url($attemptid) { return new moodle_url('/mod/quiz/summary.php', ['attempt' => $attemptid, 'cmid' => $this->get_cmid()]); } // Bits of content =========================================================. /** * If $reviewoptions->attempt is false, meaning that students can't review this * attempt at the moment, return an appropriate string explaining why. * * @param int $when One of the display_options::DURING, * IMMEDIATELY_AFTER, LATER_WHILE_OPEN or AFTER_CLOSE constants. * @param bool $short if true, return a shorter string. * @param int|null $attemptsubmittime time this attempt was submitted. (Optional, but should be given.) * @return string an appropraite message. */ public function cannot_review_message($when, $short = false, ?int $attemptsubmittime = null) { if ($attemptsubmittime === null) { debugging('It is recommended that you pass $attemptsubmittime to cannot_review_message', DEBUG_DEVELOPER); $attemptsubmittime = time(); // This will be approximately right, which is enough for the one place were it is used. } if ($short) { $langstrsuffix = 'short'; $dateformat = get_string('strftimedatetimeshort', 'langconfig'); } else { $langstrsuffix = ''; $dateformat = ''; } $reviewfrom = 0; switch ($when) { case display_options::DURING: return ''; case display_options::IMMEDIATELY_AFTER: if ($this->quiz->reviewattempt & display_options::LATER_WHILE_OPEN) { $reviewfrom = $attemptsubmittime + quiz_attempt::IMMEDIATELY_AFTER_PERIOD; break; } // Fall through. case display_options::LATER_WHILE_OPEN: if ($this->quiz->timeclose && ($this->quiz->reviewattempt & display_options::AFTER_CLOSE)) { $reviewfrom = $this->quiz->timeclose; break; } } if ($reviewfrom) { return get_string('noreviewuntil' . $langstrsuffix, 'quiz', userdate($reviewfrom, $dateformat)); } else { return get_string('noreview' . $langstrsuffix, 'quiz'); } } /** * Probably not used any more, but left for backwards compatibility. * * @param string $title the name of this particular quiz page. * @return string always returns ''. */ public function navigation($title) { global $PAGE; $PAGE->navbar->add($title); return ''; } // Private methods =========================================================. /** * Check that the definition of a particular question is loaded, and if not throw an exception. * * @param int $id a question id. */ protected function ensure_question_loaded($id) { if (isset($this->questions[$id]->_partiallyloaded)) { throw new moodle_exception('questionnotloaded', 'quiz', $this->view_url(), $id); } } /** * Return all the question types used in this quiz. * * @param boolean $includepotential if the quiz include random questions, * setting this flag to true will make the function to return all the * possible question types in the random questions category. * @return array a sorted array including the different question types. * @since Moodle 3.1 */ public function get_all_question_types_used($includepotential = false) { $questiontypes = []; // To control if we need to look in categories for questions. $qcategories = []; foreach ($this->get_questions(null, false) as $questiondata) { if ($questiondata->status == question_version_status::QUESTION_STATUS_DRAFT) { // Skip questions where all versions are draft. continue; } if ($questiondata->qtype === 'random' && $includepotential) { $filtercondition = $questiondata->filtercondition; if (!empty($filtercondition)) { $filter = $filtercondition['filter']; if (isset($filter['category'])) { foreach ($filter['category']['values'] as $catid) { $qcategories[$catid] = $filter['category']['filteroptions']['includesubcategories']; } } } } else { if (!in_array($questiondata->qtype, $questiontypes)) { $questiontypes[] = $questiondata->qtype; } } } if (!empty($qcategories)) { // We have to look for all the question types in these categories. $categoriestolook = []; foreach ($qcategories as $cat => $includesubcats) { if ($includesubcats) { $categoriestolook = array_merge($categoriestolook, question_categorylist($cat)); } else { $categoriestolook[] = $cat; } } $questiontypesincategories = question_bank::get_all_question_types_in_categories($categoriestolook); $questiontypes = array_merge($questiontypes, $questiontypesincategories); } $questiontypes = array_unique($questiontypes); sort($questiontypes); return $questiontypes; } /** * Returns an override manager instance with context and quiz loaded. * * @return \mod_quiz\local\override_manager */ public function get_override_manager(): \mod_quiz\local\override_manager { return new \mod_quiz\local\override_manager( quiz: $this->quiz, context: $this->context ); } } PK9B\lNi^^classes/grade_calculator.phpnu[. namespace mod_quiz; use coding_exception; use core\di; use core\hook; use core_component; use mod_quiz\event\quiz_grade_updated; use mod_quiz\hook\structure_modified; use mod_quiz\output\grades\grade_out_of; use qubaid_condition; use qubaid_list; use question_engine_data_mapper; use question_usage_by_activity; use stdClass; /** * This class contains all the logic for computing the grade of a quiz. * * There are two sorts of calculation which need to be done. For a single * attempt, we need to compute the total attempt score from score for each question. * And for a quiz user, we need to compute the final grade from all the separate attempt grades. * * @package mod_quiz * @copyright 2023 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class grade_calculator { /** @var float a number that is effectively zero. Used to avoid division-by-zero or underflow problems. */ const ALMOST_ZERO = 0.000005; /** @var quiz_settings the quiz for which this instance computes grades. */ protected $quizobj; /** * @var stdClass[]|null quiz_grade_items for this quiz indexed by id, sorted by sortorder, with a maxmark field added. * * Lazy-loaded when needed. See {@see ensure_grade_items_loaded()}. */ protected ?array $gradeitems = null; /** * @var ?stdClass[]|null quiz_slot for this quiz. Only ->slot and ->quizgradeitemid fields are used. * * This is either set by another class that already has the data, using {@see set_slots()} * or it is lazy-loaded when needed. See {@see ensure_slots_loaded()}. */ protected ?array $slots = null; /** * Constructor. Recommended way to get an instance is $quizobj->get_grade_calculator(); * * @param quiz_settings $quizobj */ protected function __construct(quiz_settings $quizobj) { $this->quizobj = $quizobj; } /** * Factory. The recommended way to get an instance is $quizobj->get_grade_calculator(); * * @param quiz_settings $quizobj settings of a quiz. * @return grade_calculator instance of this class for the given quiz. */ public static function create(quiz_settings $quizobj): grade_calculator { return new self($quizobj); } /** * Update the sumgrades field of the quiz. * * This needs to be called whenever the grading structure of the quiz is changed. * For example if a question is added or removed, or a question weight is changed. * * You should call {@see quiz_delete_previews()} before you call this function. */ public function recompute_quiz_sumgrades(): void { global $DB; $quiz = $this->quizobj->get_quiz(); // Update sumgrades in the database. $DB->execute(" UPDATE {quiz} SET sumgrades = COALESCE(( SELECT SUM(maxmark) FROM {quiz_slots} WHERE quizid = {quiz}.id ), 0) WHERE id = ? ", [$quiz->id]); // Update the value in memory. $quiz->sumgrades = $DB->get_field('quiz', 'sumgrades', ['id' => $quiz->id]); if ($quiz->sumgrades < self::ALMOST_ZERO && quiz_has_attempts($quiz->id)) { // If the quiz has been attempted, and the sumgrades has been // set to 0, then we must also set the maximum possible grade to 0, or // we will get a divide by zero error. self::update_quiz_maximum_grade(0); } // This class callback is deprecated, and will be removed in Moodle 4.8 (MDL-80327). // Use the structure_modified hook instead. $callbackclasses = core_component::get_plugin_list_with_class('quiz', 'quiz_structure_modified'); foreach ($callbackclasses as $callbackclass) { component_class_callback($callbackclass, 'callback', [$quiz->id], null, true); } di::get(hook\manager::class)->dispatch(new structure_modified($this->quizobj->get_structure())); } /** * Update the sumgrades field of attempts at this quiz. */ public function recompute_all_attempt_sumgrades(): void { global $DB; $dm = new question_engine_data_mapper(); $timenow = time(); $DB->execute(" UPDATE {quiz_attempts} SET timemodified = :timenow, sumgrades = ( {$dm->sum_usage_marks_subquery('uniqueid')} ) WHERE quiz = :quizid AND state = :finishedstate ", [ 'timenow' => $timenow, 'quizid' => $this->quizobj->get_quizid(), 'finishedstate' => quiz_attempt::FINISHED ]); } /** * Update the final grade at this quiz for a particular student. * * That is, given the quiz settings, and all the attempts this user has made, * compute their final grade for the quiz, as shown in the gradebook. * * The $attempts parameter is for efficiency. If you already have the data for * all this user's attempts loaded (for example from {@see quiz_get_user_attempts()} * or because you are looping through a large recordset fetched in one efficient query, * then you can pass that data here to save DB queries. * * @param int|null $userid The userid to calculate the grade for. Defaults to the current user. * @param array $attempts if you already have this user's attempt records loaded, pass them here to save queries. */ public function recompute_final_grade(?int $userid = null, array $attempts = []): void { global $DB, $USER; $quiz = $this->quizobj->get_quiz(); if (empty($userid)) { $userid = $USER->id; } if (!$attempts) { // Get all the attempts made by the user. $attempts = quiz_get_user_attempts($quiz->id, $userid); } // Calculate the best grade. $bestgrade = $this->compute_final_grade_from_attempts($attempts); $bestgrade = quiz_rescale_grade($bestgrade, $quiz, false); // Save the best grade in the database. if (is_null($bestgrade)) { $DB->delete_records('quiz_grades', ['quiz' => $quiz->id, 'userid' => $userid]); } else if ($grade = $DB->get_record('quiz_grades', ['quiz' => $quiz->id, 'userid' => $userid])) { $grade->grade = $bestgrade; $grade->timemodified = time(); $DB->update_record('quiz_grades', $grade); } else { $grade = new stdClass(); $grade->quiz = $quiz->id; $grade->userid = $userid; $grade->grade = $bestgrade; $grade->timemodified = time(); $DB->insert_record('quiz_grades', $grade); } quiz_update_grades($quiz, $userid); } /** * Calculate the overall grade for a quiz given a number of attempts by a particular user. * * @param array $attempts an array of all the user's attempts at this quiz in order. * @return float|null the overall grade, or null if the user does not have a grade. */ protected function compute_final_grade_from_attempts(array $attempts): ?float { $grademethod = $this->quizobj->get_quiz()->grademethod; switch ($grademethod) { case QUIZ_ATTEMPTFIRST: $firstattempt = reset($attempts); return $firstattempt->sumgrades; case QUIZ_ATTEMPTLAST: $lastattempt = end($attempts); return $lastattempt->sumgrades; case QUIZ_GRADEAVERAGE: $sum = 0; $count = 0; foreach ($attempts as $attempt) { if (!is_null($attempt->sumgrades)) { $sum += $attempt->sumgrades; $count++; } } if ($count == 0) { return null; } return $sum / $count; case QUIZ_GRADEHIGHEST: $max = null; foreach ($attempts as $attempt) { if ($attempt->sumgrades > $max) { $max = $attempt->sumgrades; } } return $max; default: throw new coding_exception('Unrecognised grading method ' . $grademethod); } } /** * Update the final grade at this quiz for all students. * * This function is equivalent to calling {@see recompute_final_grade()} for all * users who have attempted the quiz, but is much more efficient. */ public function recompute_all_final_grades(): void { global $DB; $quiz = $this->quizobj->get_quiz(); // If the quiz does not contain any graded questions, then there is nothing to do. if (!$quiz->sumgrades) { return; } $param = ['iquizid' => $quiz->id, 'istatefinished' => quiz_attempt::FINISHED]; $firstlastattemptjoin = "JOIN ( SELECT iquiza.userid, MIN(attempt) AS firstattempt, MAX(attempt) AS lastattempt FROM {quiz_attempts} iquiza WHERE iquiza.state = :istatefinished AND iquiza.preview = 0 AND iquiza.quiz = :iquizid GROUP BY iquiza.userid ) first_last_attempts ON first_last_attempts.userid = quiza.userid"; switch ($quiz->grademethod) { case QUIZ_ATTEMPTFIRST: // Because of the where clause, there will only be one row, but we // must still use an aggregate function. $select = 'MAX(quiza.sumgrades)'; $join = $firstlastattemptjoin; $where = 'quiza.attempt = first_last_attempts.firstattempt AND'; break; case QUIZ_ATTEMPTLAST: // Because of the where clause, there will only be one row, but we // must still use an aggregate function. $select = 'MAX(quiza.sumgrades)'; $join = $firstlastattemptjoin; $where = 'quiza.attempt = first_last_attempts.lastattempt AND'; break; case QUIZ_GRADEAVERAGE: $select = 'AVG(quiza.sumgrades)'; $join = ''; $where = ''; break; default: case QUIZ_GRADEHIGHEST: $select = 'MAX(quiza.sumgrades)'; $join = ''; $where = ''; break; } if ($quiz->sumgrades >= self::ALMOST_ZERO) { $finalgrade = $select . ' * ' . ($quiz->grade / $quiz->sumgrades); } else { $finalgrade = '0'; } $param['quizid'] = $quiz->id; $param['quizid2'] = $quiz->id; $param['quizid3'] = $quiz->id; $param['quizid4'] = $quiz->id; $param['statefinished'] = quiz_attempt::FINISHED; $param['statefinished2'] = quiz_attempt::FINISHED; $param['almostzero'] = self::ALMOST_ZERO; $finalgradesubquery = " SELECT quiza.userid, $finalgrade AS newgrade FROM {quiz_attempts} quiza $join WHERE $where quiza.state = :statefinished AND quiza.preview = 0 AND quiza.quiz = :quizid3 GROUP BY quiza.userid"; $changedgrades = $DB->get_records_sql(" SELECT users.userid, qg.id, qg.grade, newgrades.newgrade FROM ( SELECT userid FROM {quiz_grades} qg WHERE quiz = :quizid UNION SELECT DISTINCT userid FROM {quiz_attempts} quiza2 WHERE quiza2.state = :statefinished2 AND quiza2.preview = 0 AND quiza2.quiz = :quizid2 ) users LEFT JOIN {quiz_grades} qg ON qg.userid = users.userid AND qg.quiz = :quizid4 LEFT JOIN ( $finalgradesubquery ) newgrades ON newgrades.userid = users.userid WHERE ABS(newgrades.newgrade - qg.grade) > :almostzero OR ((newgrades.newgrade IS NULL OR qg.grade IS NULL) AND NOT (newgrades.newgrade IS NULL AND qg.grade IS NULL))", // The mess on the previous line is detecting where the value is // NULL in one column, and NOT NULL in the other, but SQL does // not have an XOR operator, and MS SQL server can't cope with // (newgrades.newgrade IS NULL) <> (qg.grade IS NULL). $param); $timenow = time(); $todelete = []; foreach ($changedgrades as $changedgrade) { if (is_null($changedgrade->newgrade)) { $todelete[] = $changedgrade->userid; } else if (is_null($changedgrade->grade)) { $toinsert = new stdClass(); $toinsert->quiz = $quiz->id; $toinsert->userid = $changedgrade->userid; $toinsert->timemodified = $timenow; $toinsert->grade = $changedgrade->newgrade; $DB->insert_record('quiz_grades', $toinsert); } else { $toupdate = new stdClass(); $toupdate->id = $changedgrade->id; $toupdate->grade = $changedgrade->newgrade; $toupdate->timemodified = $timenow; $DB->update_record('quiz_grades', $toupdate); } } if (!empty($todelete)) { list($test, $params) = $DB->get_in_or_equal($todelete); $DB->delete_records_select('quiz_grades', 'quiz = ? AND userid ' . $test, array_merge([$quiz->id], $params)); } } /** * Update the quiz setting for the grade the quiz is out of. * * This function will update the data in quiz_grades and quiz_feedback, and * pass the new grades on to the gradebook. * * @param float $newgrade the new maximum grade for the quiz. */ public function update_quiz_maximum_grade(float $newgrade): void { global $DB; $quiz = $this->quizobj->get_quiz(); // This is potentially expensive, so only do it if necessary. if (abs($quiz->grade - $newgrade) < self::ALMOST_ZERO) { // Nothing to do. return; } // Use a transaction. $transaction = $DB->start_delegated_transaction(); // Update the quiz table. $oldgrade = $quiz->grade; $quiz->grade = $newgrade; $timemodified = time(); $DB->update_record('quiz', (object) [ 'id' => $quiz->id, 'grade' => $newgrade, 'timemodified' => $timemodified, ]); // Rescale the grade of all quiz attempts. if ($oldgrade < $newgrade) { // The new total is bigger, so we need to recompute fully to avoid underflow problems. $this->recompute_all_final_grades(); } else { // New total smaller, so we can rescale the grades efficiently. $DB->execute(" UPDATE {quiz_grades} SET grade = ? * grade, timemodified = ? WHERE quiz = ? ", [$newgrade / $oldgrade, $timemodified, $quiz->id]); } // Rescale the overall feedback boundaries. if ($oldgrade > self::ALMOST_ZERO) { // Update the quiz_feedback table. $factor = $newgrade / $oldgrade; $DB->execute(" UPDATE {quiz_feedback} SET mingrade = ? * mingrade, maxgrade = ? * maxgrade WHERE quizid = ? ", [$factor, $factor, $quiz->id]); } // Update grade item and send all grades to gradebook. quiz_grade_item_update($quiz); quiz_update_grades($quiz); // Log quiz grade updated event. quiz_grade_updated::create([ 'context' => $this->quizobj->get_context(), 'objectid' => $quiz->id, 'other' => [ 'oldgrade' => $oldgrade + 0, // Remove trailing 0s. 'newgrade' => $newgrade, ] ])->trigger(); $transaction->allow_commit(); } /** * Ensure the {@see grade_calculator::$gradeitems} field is ready to use. */ protected function ensure_grade_items_loaded(): void { global $DB; if ($this->gradeitems !== null) { return; // Already done. } $this->gradeitems = $DB->get_records_sql(" SELECT gi.id, gi.quizid, gi.sortorder, gi.name, COALESCE(SUM(slot.maxmark), 0) AS maxmark FROM {quiz_grade_items} gi LEFT JOIN {quiz_slots} slot ON slot.quizgradeitemid = gi.id WHERE gi.quizid = ? AND slot.quizid = ? GROUP BY gi.id, gi.quizid, gi.sortorder, gi.name ORDER BY gi.sortorder ", [$this->quizobj->get_quizid(), $this->quizobj->get_quizid()]); } /** * Get the extra grade items for this quiz. * * Returned objects have fields ->id, ->quizid, ->sortorder, ->name and maxmark. * @return stdClass[] the grade items for this quiz. */ public function get_grade_items(): array { $this->ensure_grade_items_loaded(); return $this->gradeitems; } /** * Lets other code pass in the slot information, so it does note have to be re-loaded from the DB. * * @param stdClass[] $slots the data from quiz_slots. The only required fields are ->slot and ->quizgradeitemid. */ public function set_slots(array $slots): void { global $CFG; $this->slots = $slots; if ($CFG->debugdeveloper) { foreach ($slots as $slot) { if (!property_exists($slot, 'slot') || !property_exists($slot, 'quizgradeitemid')) { debugging('Slot data passed to grade_calculator::set_slots ' . 'must have at least ->slot and ->quizgradeitemid set.', DEBUG_DEVELOPER); break; // Only necessary to say this once. } } } } /** * Ensure the {@see $gradeitems} field is ready to use. */ protected function ensure_slots_loaded(): void { global $DB; if ($this->slots !== null) { return; // Already done. } $this->slots = $DB->get_records('quiz_slots', ['quizid' => $this->quizobj->get_quizid()], 'slot', 'slot, id, quizgradeitemid'); } /** * Compute the grade and maximum for each item, for an attempt where the question_usage_by_activity is available. * * @param question_usage_by_activity $quba usage for the quiz attempt we want to calculate the grades of. * @return grade_out_of[] the grade for each item where the total grade is not zero. * ->name will be set to the grade item name. Must be output through {@see format_string()}. */ public function compute_grade_item_totals(question_usage_by_activity $quba): array { $this->ensure_grade_items_loaded(); if (empty($this->gradeitems)) { // No extra grade items. return []; } $this->ensure_slots_loaded(); // Prepare a place to store the results for each grade-item. $grades = []; foreach ($this->gradeitems as $gradeitem) { $grades[$gradeitem->id] = new grade_out_of( $this->quizobj->get_quiz(), 0, $gradeitem->maxmark, name: $gradeitem->name); } // Add up the scores. foreach ($this->slots as $slot) { if (!$slot->quizgradeitemid) { continue; } $grades[$slot->quizgradeitemid]->grade += $quba->get_question_mark($slot->slot); } // Remove any grade items where the total is 0. foreach ($grades as $gradeitemid => $grade) { if ($grade->maxgrade < self::ALMOST_ZERO) { unset($grades[$gradeitemid]); } } return $grades; } /** * Compute the grade and maximum for each item, for some attempts where we only have the usage ids. * * @param int[] $qubaids array of usage ids. * @return grade_out_of[][] question_usage.id => array of grade_out_of. * ->name will be set to the grade item name. Must be output through {@see format_string()}.. */ public function compute_grade_item_totals_for_attempts(array $qubaids): array { $this->ensure_grade_items_loaded(); $grades = []; foreach ($qubaids as $qubaid) { $grades[$qubaid] = []; } if (empty($this->gradeitems || empty($qubaids))) { // Nothing to do. return $grades; } $gradesdata = $this->load_grade_item_totals(new qubaid_list($qubaids)); foreach ($qubaids as $qubaid) { foreach ($this->gradeitems as $gradeitem) { if ($gradeitem->maxmark < self::ALMOST_ZERO) { continue; } $grades[$qubaid][$gradeitem->id] = new grade_out_of( $this->quizobj->get_quiz(), $gradesdata[$qubaid][$gradeitem->id] ?? 0, $gradeitem->maxmark, name: $gradeitem->name, ); } } return $grades; } /** * Query the database return the total mark for each grade item for a set of attempts. * * @param qubaid_condition $qubaids which question_usages to computer the total marks for. * @return float[][] Array question_usage.id => quiz_grade_item.id => mark. */ public function load_grade_item_totals(qubaid_condition $qubaids): array { global $DB; $dm = new question_engine_data_mapper(); [$qalatestview, $viewparams] = $dm->question_attempt_latest_state_view('qalatest', $qubaids); $totals = $DB->get_records_sql(" SELECT " . $DB->sql_concat('qalatest.questionusageid', "'#'", 'slot.quizgradeitemid') . " AS uniquefirstcolumn, qalatest.questionusageid, slot.quizgradeitemid, SUM(qalatest.fraction * qalatest.maxmark) AS summarks FROM $qalatestview JOIN {quiz_slots} slot ON slot.slot = qalatest.slot JOIN {quiz_grade_items} qgi ON qgi.id = slot.quizgradeitemid GROUP BY qalatest.questionusageid, slot.quizgradeitemid ", $viewparams); $marks = []; foreach ($totals as $total) { $marks[$total->questionusageid][$total->quizgradeitemid] = $total->summarks + 0; // Convert to float with + 0. } return $marks; } } PK9B\Fz--classes/notification_helper.phpnu[. namespace mod_quiz; use stdClass; /** * Helper for sending quiz related notifications. * * @package mod_quiz * @copyright 2024 David Woloszyn * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class notification_helper { /** * @var int Default date range of 48 hours. */ private const DEFAULT_DATE_RANGE = (DAYSECS * 2); /** * Get all quizzes that have an approaching open date (includes users and groups with open date overrides). * * @return \moodle_recordset Returns the matching quiz records. */ public static function get_quizzes_within_date_range(): \moodle_recordset { global $DB; $timenow = self::get_time_now(); $futuretime = self::get_future_time(); $sql = "SELECT DISTINCT q.id FROM {quiz} q JOIN {course} c ON q.course = c.id JOIN {course_modules} cm ON q.id = cm.instance JOIN {modules} m ON cm.module = m.id AND m.name = :modulename LEFT JOIN {quiz_overrides} qo ON q.id = qo.quiz WHERE (q.timeopen < :futuretime OR qo.timeopen < :qo_futuretime) AND (q.timeopen > :timenow OR qo.timeopen > :qo_timenow) AND cm.visible = 1 AND c.visible = 1"; $params = [ 'timenow' => $timenow, 'futuretime' => $futuretime, 'qo_timenow' => $timenow, 'qo_futuretime' => $futuretime, 'modulename' => 'quiz', ]; return $DB->get_recordset_sql($sql, $params); } /** * Get all users that have an approaching open date within a quiz. * * @param int $quizid The quiz id. * @return array The users after all filtering has been applied. */ public static function get_users_within_quiz(int $quizid): array { // Get quiz data. $quizobj = quiz_settings::create($quizid); $quiz = $quizobj->get_quiz(); // Get our users. $users = get_enrolled_users( context: \context_module::instance($quizobj->get_cm()->id), withcapability: 'mod/quiz:attempt', userfields: 'u.id, u.firstname, u.suspended, u.auth', onlyactive: true, ); // Filter a list of users who meet the availability conditions. $info = new \core_availability\info_module($quizobj->get_cm()); $users = $info->filter_user_list($users); // Check for any override dates. $overrides = $quizobj->get_override_manager()->get_all_overrides(); foreach ($users as $key => $user) { if ($user->suspended || ($user->auth == 'nologin')) { unset($users[$key]); continue; } // Time open and time close dates can be user specific with an override. // We begin by assuming it is the same as recorded in the quiz. $user->timeopen = $quiz->timeopen; $user->timeclose = $quiz->timeclose; // Set the override type to 'none' to begin with. $user->overridetype = 'none'; // Update this user with any applicable override dates. if (!empty($overrides)) { self::update_user_with_date_overrides($overrides, $quiz->course, $user); } // If the 'timeopen' date has no value, even after overriding, unset this user. if (empty($quiz->timeopen) && empty($user->timeopen)) { unset($users[$key]); continue; } // Check the date is within our range. // We have to check here because we don't know if this quiz was selected because it only had users with overrides. if (!self::is_time_within_range($user->timeopen)) { unset($users[$key]); continue; } // Check if the user has already received this notification. $match = [ 'quizid' => strval($quizid), 'timeopen' => $user->timeopen, 'overridetype' => $user->overridetype, ]; if (self::has_user_been_sent_a_notification_already($user->id, json_encode($match))) { unset($users[$key]); } } return $users; } /** * Send the notification to the user. * * @param stdClass $user The user's custom data. */ public static function send_notification_to_user(stdClass $user): void { // Check if the user has submitted already. if (self::has_user_attempted($user)) { return; } // Get quiz data. $quizobj = quiz_settings::create($user->quizid); $quiz = $quizobj->get_quiz(); $url = $quizobj->view_url(); $stringparams = [ 'firstname' => $user->firstname, 'quizname' => format_string($quiz->name, options: ['context' => $quizobj->get_context(), 'escape' => false]), 'coursename' => format_string($quizobj->get_course()->fullname, options: ['context' => \context_course::instance($quizobj->get_course()->id), 'escape' => false]), 'timeopen' => userdate($user->timeopen), 'timeclose' => !empty($user->timeclose) ? userdate($user->timeclose) : get_string('statusna'), 'url' => $url, ]; $messagedata = [ 'user' => \core_user::get_user($user->id), 'url' => $url->out(false), 'subject' => get_string('quizopendatesoonsubject', 'mod_quiz', $stringparams), 'quizname' => $stringparams['quizname'], 'html' => get_string('quizopendatesoonhtml', 'mod_quiz', $stringparams), ]; // Prepare message object. $message = new \core\message\message(); $message->component = 'mod_quiz'; $message->name = 'quiz_open_soon'; $message->userfrom = \core_user::get_noreply_user(); $message->userto = $messagedata['user']; $message->subject = $messagedata['subject']; $message->fullmessageformat = FORMAT_HTML; $message->fullmessage = html_to_text($messagedata['html']); $message->fullmessagehtml = $messagedata['html']; $message->smallmessage = $messagedata['subject']; $message->notification = 1; $message->contexturl = $messagedata['url']; $message->contexturlname = $messagedata['quizname']; // Use custom data to avoid future notifications being sent again. $message->customdata = [ 'quizid' => $user->quizid, 'timeopen' => $user->timeopen, 'overridetype' => $user->overridetype, ]; message_send($message); } /** * Get the time now. * * @return int The time now as a timestamp. */ protected static function get_time_now(): int { return \core\di::get(\core\clock::class)->time(); } /** * Get a future time that serves as the cut-off for this notification. * * @param int|null $range Amount of seconds added to the now time (optional). * @return int The time now value plus the range. */ protected static function get_future_time(?int $range = null): int { $range = $range ?? self::DEFAULT_DATE_RANGE; return self::get_time_now() + $range; } /** * Check if a time is within the current time now and the future time values. * * @param int $time The timestamp to check. * @return boolean */ protected static function is_time_within_range(int $time): bool { return ($time > self::get_time_now() && $time < self::get_future_time()); } /** * Update user's recorded date based on the overrides. * * @param array $overrides The overrides to check. * @param int $courseid The course id of the overrides. * @param stdClass $user The user records we will be updating. */ protected static function update_user_with_date_overrides(array $overrides, int $courseid, stdClass $user): void { $usergroupids = null; foreach ($overrides as $override) { // User override. if ($override->userid === $user->id) { $user->timeopen = !empty($override->timeopen) ? $override->timeopen : $user->timeopen; $user->timeclose = !empty($override->timeclose) ? $override->timeclose : $user->timeclose; $user->overridetype = 'user'; // User override has precedence over group. Return here. return; } // Group override. if (!isset($usergroupids)) { // Only load user groups if there is at least one group override. // We only need to check the group ids and not grouping ids or visibility. $usergroups = groups_get_user_groups($courseid, $user->id); $usergroupids = array_merge(...array_values($usergroups)); } if (!empty($override->groupid) && in_array($override->groupid, $usergroupids)) { // If user is a member of multiple groups, and we have set this already, use the earliest date. if ($user->overridetype === 'group' && $user->timeopen < $override->timeopen) { continue; } $user->timeopen = !empty($override->timeopen) ? $override->timeopen : $user->timeopen; $user->timeclose = !empty($override->timeclose) ? $override->timeclose : $user->timeclose; $user->overridetype = 'group'; } } } /** * Check if a user has attempted this quiz already. * * @param stdClass $user The user record we will be checking. * @return bool Return true if attempt found. */ protected static function has_user_attempted(stdClass $user): bool { global $DB; return $DB->record_exists('quiz_attempts', [ 'quiz' => $user->quizid, 'userid' => $user->id, ]); } /** * Check if a user has been sent a notification already. * * @param int $userid The user id. * @param string $match The custom data string to match on. * @return bool Returns true if already sent. */ protected static function has_user_been_sent_a_notification_already(int $userid, string $match): bool { global $DB; $sql = "SELECT COUNT(n.id) FROM {notifications} n WHERE " . $DB->sql_compare_text('n.customdata', 255) . " = " . $DB->sql_compare_text(':match', 255) . " AND n.useridto = :userid"; $result = $DB->count_records_sql($sql, [ 'userid' => $userid, 'match' => $match, ]); return ($result > 0); } } PK9B\]ă!classes/plugininfo/quizaccess.phpnu[. /** * Subplugin info class. * * @package mod_quiz * @copyright 2013 Petr Skoda {@link http://skodak.org} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace mod_quiz\plugininfo; use core\plugininfo\base; defined('MOODLE_INTERNAL') || die(); class quizaccess extends base { public function is_uninstall_allowed() { // Only allow uninstall of non-core access rules. return !$this->is_standard(); } } PK9B\+Ƀclasses/plugininfo/quiz.phpnu[. /** * Subplugin info class. * * @package mod_quiz * @copyright 2013 Petr Skoda {@link http://skodak.org} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace mod_quiz\plugininfo; use core\plugininfo\base; defined('MOODLE_INTERNAL') || die(); class quiz extends base { public function is_uninstall_allowed() { return true; } /** * Pre-uninstall hook. * * This is intended for disabling of plugin, some DB table purging, etc. * * NOTE: to be called from uninstall_plugin() only. * @private */ public function uninstall_cleanup() { global $DB; // Do the opposite of db/install.php scripts - deregister the report. $DB->delete_records('quiz_reports', ['name' => $this->name]); parent::uninstall_cleanup(); } } PK9B\YY*classes/admin/overdue_handling_setting.phpnu[. namespace mod_quiz\admin; /** * Admin settings class for the quiz overdue attempt handling method. * * Just so we can lazy-load the choices. * * @package mod_quiz * @category admin * @copyright 2011 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class overdue_handling_setting extends \admin_setting_configselect_with_advanced { public function load_choices() { global $CFG; if (is_array($this->choices)) { return true; } require_once($CFG->dirroot . '/mod/quiz/locallib.php'); $this->choices = quiz_get_overdue_handling_options(); return true; } } PK9B\G||*classes/admin/browser_security_setting.phpnu[. namespace mod_quiz\admin; use mod_quiz\access_manager; /** * Admin settings class for the quiz browser security option. * * Just so we can lazy-load the choices. * * @package mod_quiz * @category admin * @copyright 2011 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class browser_security_setting extends \admin_setting_configselect_with_advanced { public function load_choices() { global $CFG; if (is_array($this->choices)) { return true; } require_once($CFG->dirroot . '/mod/quiz/locallib.php'); $this->choices = access_manager::get_browser_security_choices(); return true; } } PK9B\Q|VV$classes/admin/user_image_setting.phpnu[. namespace mod_quiz\admin; /** * Admin settings class for the choices for how to display the user's image. * * Just so we can lazy-load the choices. * * @package mod_quiz * @category admin * @copyright 2011 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class user_image_setting extends \admin_setting_configselect_with_advanced { public function load_choices() { global $CFG; if (is_array($this->choices)) { return true; } require_once($CFG->dirroot . '/mod/quiz/locallib.php'); $this->choices = quiz_get_user_image_options(); return true; } } PK9B\Ѵ;;&classes/admin/grade_method_setting.phpnu[. namespace mod_quiz\admin; /** * Admin settings class for the quiz grading method. * * Just so we can lazy-load the choices. * * @package mod_quiz * @category admin * @copyright 2011 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class grade_method_setting extends \admin_setting_configselect_with_advanced { public function load_choices() { global $CFG; if (is_array($this->choices)) { return true; } require_once($CFG->dirroot . '/mod/quiz/locallib.php'); $this->choices = quiz_get_grading_options(); return true; } } PK9B\<)) classes/admin/review_setting.phpnu[. namespace mod_quiz\admin; /** * Admin settings class for the quiz review options. * * @package mod_quiz * @category admin * @copyright 2008 Tim Hunt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class review_setting extends \admin_setting { /** * @var integer should match the constants defined in * {@see display_options}. Copied for performance reasons. */ const DURING = 0x10000; /** * @var integer should match the constants defined in * {@see display_options}. Copied for performance reasons. */ const IMMEDIATELY_AFTER = 0x01000; /** * @var integer should match the constants defined in * {@see display_options}. Copied for performance reasons. */ const LATER_WHILE_OPEN = 0x00100; /** * @var integer should match the constants defined in * {@see display_options}. Copied for performance reasons. */ const AFTER_CLOSE = 0x00010; /** * @var boolean|null forced checked / disabled attributes for the during time. */ protected $duringstate; /** * This should match {@link mod_quiz_mod_form::$reviewfields} but copied * here because generating the admin tree needs to be fast. * @return array */ public static function fields() { return [ 'attempt' => get_string('theattempt', 'quiz'), 'correctness' => get_string('whethercorrect', 'question'), 'maxmarks' => get_string('maxmarks', 'quiz'), 'marks' => get_string('marks', 'question'), 'specificfeedback' => get_string('specificfeedback', 'question'), 'generalfeedback' => get_string('generalfeedback', 'question'), 'rightanswer' => get_string('rightanswer', 'question'), 'overallfeedback' => get_string('overallfeedback', 'quiz'), ]; } /** * Constructor. * * @param string $name unique ascii name, either 'mysetting' for settings that in config, * or 'myplugin/mysetting' for ones in config_plugins. * @param string $visiblename localised name * @param string $description localised long description * @param mixed $defaultsetting string or array depending on implementation * @param bool|null $duringstate */ public function __construct($name, $visiblename, $description, $defaultsetting, $duringstate = null) { $this->duringstate = $duringstate; parent::__construct($name, $visiblename, $description, $defaultsetting); } /** * Return the combination that means all times. * @return int all times. */ public static function all_on() { return self::DURING | self::IMMEDIATELY_AFTER | self::LATER_WHILE_OPEN | self::AFTER_CLOSE; } /** * Get an array of the names of all the possible times. * @return array an array of time constant => lang string. */ protected static function times() { return [ self::DURING => get_string('reviewduring', 'quiz'), self::IMMEDIATELY_AFTER => get_string('reviewimmediately', 'quiz'), self::LATER_WHILE_OPEN => get_string('reviewopen', 'quiz'), self::AFTER_CLOSE => get_string('reviewclosed', 'quiz'), ]; } protected function normalise_data($data) { $times = self::times(); $value = 0; foreach ($times as $timemask => $name) { if ($timemask == self::DURING && !is_null($this->duringstate)) { if ($this->duringstate) { $value += $timemask; } } else if (!empty($data[$timemask])) { $value += $timemask; } } return $value; } public function get_setting() { return $this->config_read($this->name); } public function write_setting($data) { if (is_array($data) || empty($data)) { $data = $this->normalise_data($data); } $this->config_write($this->name, $data); return ''; } public function output_html($data, $query = '') { if (is_array($data) || empty($data)) { $data = $this->normalise_data($data); } $return = '
'; foreach (self::times() as $timemask => $namestring) { $id = $this->get_id(). '_' . $timemask; $state = ''; if ($data & $timemask) { $state = 'checked="checked" '; } if ($timemask == self::DURING && !is_null($this->duringstate)) { $state = 'disabled="disabled" '; if ($this->duringstate) { $state .= 'checked="checked" '; } } $return .= ' \n"; } $return .= "
\n"; return format_admin_setting($this, $this->visiblename, $return, $this->description, true, '', get_string('everythingon', 'quiz'), $query); } } PK9B\jclasses/search/activity.phpnu[. /** * Search area for mod_quiz activities. * * @package mod_quiz * @copyright 2015 David Monllao {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace mod_quiz\search; defined('MOODLE_INTERNAL') || die(); /** * Search area for mod_quiz activities. * * @package mod_quiz * @copyright 2015 David Monllao {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class activity extends \core_search\base_activity { /** * Returns true if this area uses file indexing. * * @return bool */ public function uses_file_indexing() { return true; } } PK9B\ &classes/hook/attempt_state_changed.phpnu[. namespace mod_quiz\hook; use core\attribute; /** * A quiz attempt changed state. * * @package mod_quiz * @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 */ #[attribute\label('A quiz attempt changed state.')] #[attribute\tags('quiz', 'attempt')] #[attribute\hook\replaces_callbacks('quiz_attempt_deleted::callback')] class attempt_state_changed { /** * Create a new hook instance. * * @param ?\stdClass $originalattempt The original database record for the attempt, null if it has just been created. * @param ?\stdClass $updatedattempt The updated database record of the new attempt, null if it has just been deleted. */ public function __construct( /** @var ?\stdClass The original database record for the attempt, null if it has just been created. */ protected ?\stdClass $originalattempt, /** @var ?\stdClass The updated database record of the new attempt, null if it has just been deleted. */ protected ?\stdClass $updatedattempt, ) { if (is_null($this->originalattempt) && is_null($this->updatedattempt)) { throw new \InvalidArgumentException('originalattempt and updatedattempt cannot both be null.'); } if ( !is_null($this->originalattempt) && !is_null($this->updatedattempt) && $this->originalattempt->id != $this->updatedattempt->id ) { throw new \InvalidArgumentException('originalattempt and updatedattempt must have the same id.'); } } /** * Get the original attempt, null if it has just been created. * * @return ?\stdClass */ public function get_original_attempt(): ?\stdClass { return $this->originalattempt; } /** * Get the updated attempt, null if it has just been deleted. * * @return ?\stdClass */ public function get_updated_attempt(): ?\stdClass { return $this->updatedattempt; } } PK9B\DĬ#classes/hook/structure_modified.phpnu[. namespace mod_quiz\hook; use core\attribute; use mod_quiz\structure; /** * The quiz structure has been modified * * @package mod_quiz * @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 */ #[attribute\label('The quiz structure has been modified')] #[attribute\tags('quiz', 'structure')] #[attribute\hook\replaces_callbacks('quiz_structure_modified::callback')] class structure_modified { /** * Create a new hook with the modified structure. * * @param structure $structure The new structure. */ public function __construct( /** @var structure The new structure */ protected structure $structure ) { } /** * Returns the new structure of the quiz. * * @return structure The structure object. */ public function get_structure(): structure { return $this->structure; } } PK9B\;d~d~classes/quiz_attempt.phpnu[. namespace mod_quiz; use action_link; use block_contents; use cm_info; use coding_exception; use context_module; use core\di; use core\hook; use Exception; use html_writer; use mod_quiz\hook\attempt_state_changed; use mod_quiz\output\grades\grade_out_of; use mod_quiz\output\links_to_other_attempts; use mod_quiz\output\renderer; use mod_quiz\question\bank\qbank_helper; use mod_quiz\question\display_options; use moodle_exception; use moodle_url; use popup_action; use qtype_description_question; use question_attempt; use question_bank; use question_display_options; use question_engine; use question_out_of_sequence_exception; use question_state; use question_usage_by_activity; use stdClass; /** * This class represents one user's attempt at a particular quiz. * * @package mod_quiz * @copyright 2008 Tim Hunt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class quiz_attempt { /** @var string to identify the "not started" state, when an attempt has been pre-generated. */ const NOT_STARTED = 'notstarted'; /** @var string to identify the in progress state. */ const IN_PROGRESS = 'inprogress'; /** @var string to identify the overdue state. */ const OVERDUE = 'overdue'; /** @var string to identify the submitted state, when an attempt is awaiting grading. */ const SUBMITTED = 'submitted'; /** @var string to identify the finished state. */ const FINISHED = 'finished'; /** @var string to identify the abandoned state. */ const ABANDONED = 'abandoned'; /** @var int maximum number of slots in the quiz for the review page to default to show all. */ const MAX_SLOTS_FOR_DEFAULT_REVIEW_SHOW_ALL = 50; /** @var int amount of time considered 'immedately after the attempt', in seconds. */ const IMMEDIATELY_AFTER_PERIOD = 2 * MINSECS; /** @var quiz_settings object containing the quiz settings. */ protected $quizobj; /** @var stdClass the quiz_attempts row. */ protected $attempt; /** * @var question_usage_by_activity|null the question usage for this quiz attempt. * * Only available after load_questions is called, e.g. if the class is constructed * with $loadquestions true (the default). */ protected ?question_usage_by_activity $quba = null; /** * @var array of slot information. These objects contain ->id (int), ->slot (int), * ->requireprevious (bool), ->displaynumber (string) and quizgradeitemid (int) from the DB. * They do not contain page - get that from {@see get_question_page()} - * or maxmark - get that from $this->quba. It is augmented with * ->firstinsection (bool), ->section (stdClass from $this->sections). */ protected $slots; /** @var array of quiz_sections rows, with a ->lastslot field added. */ protected $sections; /** @var grade_calculator instance for this quiz. */ protected grade_calculator $gradecalculator; /** * @var grade_out_of[]|null can be used to store the total grade for each section. * * This is typically done when one or more attempts are created without load_questions. * This lets the mark totals be passed in and later used. Format of this array should * match what {@see grade_calculator::compute_grade_item_totals()} would return. */ protected ?array $gradeitemmarks = null; /** @var array page no => array of slot numbers on the page in order. */ protected $pagelayout; /** @var array slot => displayed question number for this slot. (E.g. 1, 2, 3 or 'i'.) */ protected $questionnumbers; /** @var array slot => page number for this slot. */ protected $questionpages; /** @var display_options cache for the appropriate review options. */ protected $reviewoptions = null; // Constructor =============================================================. /** * Constructor assuming we already have the necessary data loaded. * * @param stdClass $attempt the row of the quiz_attempts table. * @param stdClass $quiz the quiz object for this attempt and user. * @param cm_info $cm the course_module object for this quiz. * @param stdClass $course the row from the course table for the course we belong to. * @param bool $loadquestions (optional) if true, the default, load all the details * of the state of each question. Else just set up the basic details of the attempt. */ public function __construct($attempt, $quiz, $cm, $course, $loadquestions = true) { $this->attempt = $attempt; $this->quizobj = new quiz_settings($quiz, $cm, $course); $this->gradecalculator = $this->quizobj->get_grade_calculator(); if ($loadquestions) { $this->load_questions(); $this->gradecalculator->set_slots($this->slots); } } /** * Used by {create()} and {create_from_usage_id()}. * * @param array $conditions passed to $DB->get_record('quiz_attempts', $conditions). * @return quiz_attempt the desired instance of this class. */ protected static function create_helper($conditions) { global $DB; $attempt = $DB->get_record('quiz_attempts', $conditions, '*', MUST_EXIST); $quiz = access_manager::load_quiz_and_settings($attempt->quiz); $course = get_course($quiz->course); $cm = get_coursemodule_from_instance('quiz', $quiz->id, $course->id, false, MUST_EXIST); // Update quiz with override information. $quiz = quiz_update_effective_access($quiz, $attempt->userid); return new quiz_attempt($attempt, $quiz, $cm, $course); } /** * Static function to create a new quiz_attempt object given an attemptid. * * @param int $attemptid the attempt id. * @return quiz_attempt the new quiz_attempt object */ public static function create($attemptid) { return self::create_helper(['id' => $attemptid]); } /** * Static function to create a new quiz_attempt object given a usage id. * * @param int $usageid the attempt usage id. * @return quiz_attempt the new quiz_attempt object */ public static function create_from_usage_id($usageid) { return self::create_helper(['uniqueid' => $usageid]); } /** * Get a human-readable name for one of the quiz attempt states. * * @param string $state one of the state constants like IN_PROGRESS. * @return string the human-readable state name. */ public static function state_name($state) { return quiz_attempt_state_name($state); } /** * This method can be called later if the object was constructed with $loadquestions = false. */ public function load_questions() { global $DB; if (isset($this->quba)) { throw new coding_exception('This quiz attempt has already had the questions loaded.'); } $this->quba = question_engine::load_questions_usage_by_activity($this->attempt->uniqueid); $this->slots = $DB->get_records('quiz_slots', ['quizid' => $this->get_quizid()], 'slot', 'slot, id, requireprevious, displaynumber, quizgradeitemid'); $this->sections = array_values($DB->get_records('quiz_sections', ['quizid' => $this->get_quizid()], 'firstslot')); $this->link_sections_and_slots(); $this->determine_layout(); $this->number_questions(); } /** * Preload all attempt step users to show in Response history. */ public function preload_all_attempt_step_users(): void { $this->quba->preload_all_step_users(); } /** * Let each slot know which section it is part of. */ protected function link_sections_and_slots() { foreach ($this->sections as $i => $section) { if (isset($this->sections[$i + 1])) { $section->lastslot = $this->sections[$i + 1]->firstslot - 1; } else { $section->lastslot = count($this->slots); } for ($slot = $section->firstslot; $slot <= $section->lastslot; $slot += 1) { $this->slots[$slot]->section = $section; } } } /** * Parse attempt->layout to populate the other arrays that represent the layout. */ protected function determine_layout() { // Break up the layout string into pages. $pagelayouts = explode(',0', $this->attempt->layout); // Strip off any empty last page (normally there is one). if (end($pagelayouts) == '') { array_pop($pagelayouts); } // File the ids into the arrays. // Tracking which is the first slot in each section in this attempt is // trickier than you might guess, since the slots in this section // may be shuffled, so $section->firstslot (the lowest numbered slot in // the section) may not be the first one. $unseensections = $this->sections; $this->pagelayout = []; foreach ($pagelayouts as $page => $pagelayout) { $pagelayout = trim($pagelayout, ','); if ($pagelayout == '') { continue; } $this->pagelayout[$page] = explode(',', $pagelayout); foreach ($this->pagelayout[$page] as $slot) { $sectionkey = array_search($this->slots[$slot]->section, $unseensections); if ($sectionkey !== false) { $this->slots[$slot]->firstinsection = true; unset($unseensections[$sectionkey]); } else { $this->slots[$slot]->firstinsection = false; } } } } /** * Work out the number to display for each question/slot. */ protected function number_questions() { $number = 1; foreach ($this->pagelayout as $page => $slots) { foreach ($slots as $slot) { if ($length = $this->is_real_question($slot)) { // Whether question numbering is customised or is numeric and automatically incremented. if ($this->slots[$slot]->displaynumber !== null && $this->slots[$slot]->displaynumber !== '' && !$this->slots[$slot]->section->shufflequestions) { $this->questionnumbers[$slot] = $this->slots[$slot]->displaynumber; } else { $this->questionnumbers[$slot] = (string) $number; } $number += $length; } else { $this->questionnumbers[$slot] = get_string('infoshort', 'quiz'); } $this->questionpages[$slot] = $page; } } } /** * If the given page number is out of range (before the first page, or after * the last page, change it to be within range). * * @param int $page the requested page number. * @return int a safe page number to use. */ public function force_page_number_into_range($page) { return min(max($page, 0), count($this->pagelayout) - 1); } // Simple getters ==========================================================. /** * Get the raw quiz settings object. * * @return stdClass */ public function get_quiz() { return $this->quizobj->get_quiz(); } /** * Get the {@see seb_quiz_settings} object for this quiz. * * @return quiz_settings */ public function get_quizobj() { return $this->quizobj; } /** * Git the id of the course this quiz belongs to. * * @return int the course id. */ public function get_courseid() { return $this->quizobj->get_courseid(); } /** * Get the course settings object. * * @return stdClass the course settings object. */ public function get_course() { return $this->quizobj->get_course(); } /** * Get the quiz id. * * @return int the quiz id. */ public function get_quizid() { return $this->quizobj->get_quizid(); } /** * Get the name of this quiz. * * @return string Quiz name, directly from the database (format_string must be called before output). */ public function get_quiz_name() { return $this->quizobj->get_quiz_name(); } /** * Get the quiz navigation method. * * @return int QUIZ_NAVMETHOD_FREE or QUIZ_NAVMETHOD_SEQ. */ public function get_navigation_method() { return $this->quizobj->get_navigation_method(); } /** * Get the course_module for this quiz. * * @return cm_info the course_module object. */ public function get_cm() { return $this->quizobj->get_cm(); } /** * Get the course-module id. * * @return int the course_module id. */ public function get_cmid() { return $this->quizobj->get_cmid(); } /** * Get the quiz context. * * @return context_module the context of the quiz this attempt belongs to. */ public function get_context(): context_module { return $this->quizobj->get_context(); } /** * Is the current user is someone who previews the quiz, rather than attempting it? * * @return bool true user is a preview user. False, if they can do real attempts. */ public function is_preview_user() { return $this->quizobj->is_preview_user(); } /** * Get the number of attempts the user is allowed at this quiz. * * @return int the number of attempts allowed at this quiz (0 = infinite). */ public function get_num_attempts_allowed() { return $this->quizobj->get_num_attempts_allowed(); } /** * Get the number of quizzes in the quiz attempt. * * @return int number pages. */ public function get_num_pages() { return count($this->pagelayout); } /** * Get the access_manager for this quiz attempt. * * @param int $timenow the current time as a unix timestamp. * @return access_manager and instance of the access_manager class * for this quiz at this time. */ public function get_access_manager($timenow) { return $this->quizobj->get_access_manager($timenow); } /** * Get the id of this attempt. * * @return int the attempt id. */ public function get_attemptid() { return $this->attempt->id; } /** * Get the question-usage id corresponding to this quiz attempt. * * @return int the attempt unique id. */ public function get_uniqueid() { return $this->attempt->uniqueid; } /** * Get the raw quiz attempt object. * * @return stdClass the row from the quiz_attempts table. */ public function get_attempt() { return $this->attempt; } /** * Get the attempt number. * * @return int the number of this attempt (is it this user's first, second, ... attempt). */ public function get_attempt_number() { return $this->attempt->attempt; } /** * Get the state of this attempt. * * @return string {@see IN_PROGRESS}, {@see FINISHED}, {@see OVERDUE} or {@see ABANDONED}. */ public function get_state() { return $this->attempt->state; } /** * Get the id of the user this attempt belongs to. * @return int user id. */ public function get_userid() { return $this->attempt->userid; } /** * Get the current page of the attempt * @return int page number. */ public function get_currentpage() { return $this->attempt->currentpage; } /** * Compute the grade and maximum grade for each grade item, for this attempt. * * @return grade_out_of[] the grade for each item where the total grade is not zero. * ->name will be set to the grade item name. Must be output through {@see format_string()}. */ public function get_grade_item_totals(): array { if ($this->gradeitemmarks !== null) { return $this->gradeitemmarks; } else if ($this->quba !== null) { return $this->gradecalculator->compute_grade_item_totals($this->quba); } else { throw new coding_exception('To call get_grade_item_totals, you must either have ' . '->quba set (e.g. create this class with $loadquestions true) or you must ' . 'previously have computed the totals (e.g. with ' . 'grade_calculator::compute_grade_item_totals_for_attempts() and pass them to ' . '->set_grade_item_totals().'); } } /** * Set the total grade for each grade_item for this quiz. * * You only need to do this if the instance of this class was created with $loadquestions false. * Typically, you will have got the grades from {@see grade_calculator::compute_grade_item_totals_for_attempts()}. * * @param grade_out_of[] $grades same form as {@see grade_calculator::compute_grade_item_totals()} would return. */ public function set_grade_item_totals(array $grades): void { $this->gradeitemmarks = $grades; } /** * Get the total number of marks that the user had scored on all the questions. * * @return float */ public function get_sum_marks() { return $this->attempt->sumgrades; } /** * Has this attempt been finished? * * States {@see FINISHED} and {@see ABANDONED} are both considered finished in this state. * Other states are not. * * @return bool */ public function is_finished() { return $this->attempt->state == self::FINISHED || $this->attempt->state == self::ABANDONED; } /** * Is this attempt a preview? * * @return bool true if it is. */ public function is_preview() { return $this->attempt->preview; } /** * Does this attempt belong to the current user? * * @return bool true => own attempt/preview. false => reviewing someone else's. */ public function is_own_attempt() { global $USER; return $this->attempt->userid == $USER->id; } /** * Is this attempt is a preview belonging to the current user. * * @return bool true if it is. */ public function is_own_preview() { return $this->is_own_attempt() && $this->is_preview_user() && $this->attempt->preview; } /** * Is the current user allowed to review this attempt. This applies when * {@see is_own_attempt()} returns false. * * @return bool whether the review should be allowed. */ public function is_review_allowed() { if (!$this->has_capability('mod/quiz:viewreports')) { return false; } $cm = $this->get_cm(); if ($this->has_capability('moodle/site:accessallgroups') || groups_get_activity_groupmode($cm) != SEPARATEGROUPS) { return true; } // Check the users have at least one group in common. $teachersgroups = groups_get_activity_allowed_groups($cm); $studentsgroups = groups_get_all_groups( $cm->course, $this->attempt->userid, $cm->groupingid); return $teachersgroups && $studentsgroups && array_intersect(array_keys($teachersgroups), array_keys($studentsgroups)); } /** * Has the student, in this attempt, engaged with the quiz in a non-trivial way? * * That is, is there any question worth a non-zero number of marks, where * the student has made some response that we have saved? * * @return bool true if we have saved a response for at least one graded question. */ public function has_response_to_at_least_one_graded_question() { foreach ($this->quba->get_attempt_iterator() as $qa) { if ($qa->get_max_mark() == 0) { continue; } if ($qa->get_num_steps() > 1) { return true; } } return false; } /** * Do any questions in this attempt need to be graded manually? * * @return bool True if we have at least one question still needs manual grading. */ public function requires_manual_grading(): bool { return $this->quba->get_total_mark() === null; } /** * Get extra summary information about this attempt. * * Some behaviours may be able to provide interesting summary information * about the attempt as a whole, and this method provides access to that data. * To see how this works, try setting a quiz to one of the CBM behaviours, * and then look at the extra information displayed at the top of the quiz * review page once you have submitted an attempt. * * In the return value, the array keys are identifiers of the form * qbehaviour_behaviourname_meaningfullkey. For qbehaviour_deferredcbm_highsummary. * The values are arrays with two items, title and content. Each of these * will be either a string, or a renderable. * * If this method is called before load_questions() is called, then an empty array is returned. * * @param question_display_options $options the display options for this quiz attempt at this time. * @return array as described above. */ public function get_additional_summary_data(question_display_options $options) { if (!isset($this->quba)) { return []; } return $this->quba->get_summary_information($options); } /** * Get the overall feedback corresponding to a particular mark. * * @param number $grade a particular grade. * @return string the feedback. */ public function get_overall_feedback($grade) { return quiz_feedback_for_grade($grade, $this->get_quiz(), $this->quizobj->get_context()); } /** * Wrapper round the has_capability function that automatically passes in the quiz context. * * @param string $capability the name of the capability to check. For example mod/forum:view. * @param int|null $userid A user id. If null checks the permissions of the current user. * @param bool $doanything If false, ignore effect of admin role assignment. * @return boolean true if the user has this capability, otherwise false. */ public function has_capability($capability, $userid = null, $doanything = true) { return $this->quizobj->has_capability($capability, $userid, $doanything); } /** * Wrapper round the require_capability function that automatically passes in the quiz context. * * @param string $capability the name of the capability to check. For example mod/forum:view. * @param int|null $userid A user id. If null checks the permissions of the current user. * @param bool $doanything If false, ignore effect of admin role assignment. */ public function require_capability($capability, $userid = null, $doanything = true) { $this->quizobj->require_capability($capability, $userid, $doanything); } /** * Check the appropriate capability to see whether this user may review their own attempt. * If not, prints an error. */ public function check_review_capability() { if ($this->get_attempt_state() == display_options::IMMEDIATELY_AFTER) { $capability = 'mod/quiz:attempt'; } else { $capability = 'mod/quiz:reviewmyattempts'; } // These next tests are in a slightly funny order. The point is that the // common and most performance-critical case is students attempting a quiz, // so we want to check that permission first. if ($this->has_capability($capability)) { // User has the permission that lets you do the quiz as a student. Fine. return; } if ($this->has_capability('mod/quiz:viewreports') || $this->has_capability('mod/quiz:preview')) { // User has the permission that lets teachers review. Fine. return; } // They should not be here. Trigger the standard no-permission error // but using the name of the student capability. // We know this will fail. We just want the standard exception thrown. $this->require_capability($capability); } /** * Checks whether a user may navigate to a particular slot. * * @param int $slot the target slot (currently does not affect the answer). * @return bool true if the navigation should be allowed. */ public function can_navigate_to($slot) { if ($this->attempt->state == self::OVERDUE) { // When the attempt is overdue, students can only see the // attempt summary page and cannot navigate anywhere else. return false; } return $this->get_navigation_method() == QUIZ_NAVMETHOD_FREE; } /** * Get where we are time-wise in relation to this attempt and the quiz settings. * * @return int one of {@see display_options::DURING}, {@see display_options::IMMEDIATELY_AFTER}, * {@see display_options::LATER_WHILE_OPEN} or {@see display_options::AFTER_CLOSE}. */ public function get_attempt_state() { return quiz_attempt_state($this->get_quiz(), $this->attempt); } /** * Wrapper that the correct display_options for this quiz at the * moment. * * @param bool $reviewing true for options when reviewing, false for when attempting. * @return question_display_options the render options for this user on this attempt. */ public function get_display_options($reviewing) { if ($reviewing) { if (is_null($this->reviewoptions)) { $this->reviewoptions = quiz_get_review_options($this->get_quiz(), $this->attempt, $this->quizobj->get_context()); if ($this->is_own_preview()) { // It should always be possible for a teacher to review their // own preview irrespective of the review options settings. $this->reviewoptions->attempt = true; } } return $this->reviewoptions; } else { $options = display_options::make_from_quiz($this->get_quiz(), display_options::DURING); $options->flags = quiz_get_flag_option($this->attempt, $this->quizobj->get_context()); return $options; } } /** * Wrapper that the correct display_options for this quiz at the * moment. * * @param bool $reviewing true for review page, else attempt page. * @param int $slot which question is being displayed. * @param moodle_url $thispageurl to return to after the editing form is * submitted or cancelled. If null, no edit link will be generated. * * @return question_display_options the render options for this user on this * attempt, with extra info to generate an edit link, if applicable. */ public function get_display_options_with_edit_link($reviewing, $slot, $thispageurl) { $options = clone($this->get_display_options($reviewing)); if (!$thispageurl) { return $options; } if (!($reviewing || $this->is_preview())) { return $options; } $question = $this->quba->get_question($slot, false); if (!question_has_capability_on($question, 'edit', $question->category)) { return $options; } $options->editquestionparams['cmid'] = $this->get_cmid(); $options->editquestionparams['returnurl'] = $thispageurl; return $options; } /** * Is a particular page the last one in the quiz? * * @param int $page a page number * @return bool true if that is the last page of the quiz. */ public function is_last_page($page) { return $page == count($this->pagelayout) - 1; } /** * Return the list of slot numbers for either a given page of the quiz, or for the * whole quiz. * * @param mixed $page string 'all' or integer page number. * @return array the requested list of slot numbers. */ public function get_slots($page = 'all') { if ($page === 'all') { $numbers = []; foreach ($this->pagelayout as $numbersonpage) { $numbers = array_merge($numbers, $numbersonpage); } return $numbers; } else { return $this->pagelayout[$page]; } } /** * Return the list of slot numbers for either a given page of the quiz, or for the * whole quiz. * * @param mixed $page string 'all' or integer page number. * @return array the requested list of slot numbers. */ public function get_active_slots($page = 'all') { $activeslots = []; foreach ($this->get_slots($page) as $slot) { if (!$this->is_blocked_by_previous_question($slot)) { $activeslots[] = $slot; } } return $activeslots; } /** * Helper method for unit tests. Get the underlying question usage object. * * @return question_usage_by_activity the usage. */ public function get_question_usage() { if (!(PHPUNIT_TEST || defined('BEHAT_TEST'))) { throw new coding_exception('get_question_usage is only for use in unit tests. ' . 'For other operations, use the quiz_attempt api, or extend it properly.'); } return $this->quba; } /** * Get the question_attempt object for a particular question in this attempt. * * @param int $slot the number used to identify this question within this attempt. * @return question_attempt the requested question_attempt. */ public function get_question_attempt($slot) { return $this->quba->get_question_attempt($slot); } /** * Get all the question_attempt objects that have ever appeared in a given slot. * * This relates to the 'Try another question like this one' feature. * * @param int $slot the number used to identify this question within this attempt. * @return question_attempt[] the attempts. */ public function all_question_attempts_originally_in_slot($slot) { $qas = []; foreach ($this->quba->get_attempt_iterator() as $qa) { if ($qa->get_metadata('originalslot') == $slot) { $qas[] = $qa; } } $qas[] = $this->quba->get_question_attempt($slot); return $qas; } /** * Is a particular question in this attempt a real question, or something like a description. * * @param int $slot the number used to identify this question within this attempt. * @return int whether that question is a real question. Actually returns the * question length, which could theoretically be greater than one. */ public function is_real_question($slot) { return $this->quba->get_question($slot, false)->length; } /** * Is a particular question in this attempt a real question, or something like a description. * * @param int $slot the number used to identify this question within this attempt. * @return bool whether that question is a real question. */ public function is_question_flagged($slot) { return $this->quba->get_question_attempt($slot)->is_flagged(); } /** * Checks whether the question in this slot requires the previous * question to have been completed. * * @param int $slot the number used to identify this question within this attempt. * @return bool whether the previous question must have been completed before * this one can be seen. */ public function is_blocked_by_previous_question($slot) { return $slot > 1 && isset($this->slots[$slot]) && $this->slots[$slot]->requireprevious && !$this->slots[$slot]->section->shufflequestions && !$this->slots[$slot - 1]->section->shufflequestions && $this->get_navigation_method() != QUIZ_NAVMETHOD_SEQ && !$this->get_question_state($slot - 1)->is_finished() && $this->quba->can_question_finish_during_attempt($slot - 1); } /** * Is it possible for this question to be re-started within this attempt? * * @param int $slot the number used to identify this question within this attempt. * @return bool whether the student should be given the option to restart this question now. */ public function can_question_be_redone_now($slot) { return $this->get_quiz()->canredoquestions && !$this->is_finished() && $this->get_question_state($slot)->is_finished(); } /** * Given a slot in this attempt, which may or not be a redone question, return the original slot. * * @param int $slot identifies a particular question in this attempt. * @return int the slot where this question was originally. */ public function get_original_slot($slot) { $originalslot = $this->quba->get_question_attempt_metadata($slot, 'originalslot'); if ($originalslot) { return $originalslot; } else { return $slot; } } /** * Get the displayed question number for a slot. * * @param int $slot the number used to identify this question within this attempt. * @return string the displayed question number for the question in this slot. * For example '1', '2', '3' or 'i'. */ public function get_question_number($slot): string { return $this->questionnumbers[$slot]; } /** * If the section heading, if any, that should come just before this slot. * * @param int $slot identifies a particular question in this attempt. * @return string|null the required heading, or null if there is not one here. */ public function get_heading_before_slot($slot) { if ($this->slots[$slot]->firstinsection) { return $this->slots[$slot]->section->heading; } else { return null; } } /** * Return the page of the quiz where this question appears. * * @param int $slot the number used to identify this question within this attempt. * @return int the page of the quiz this question appears on. */ public function get_question_page($slot) { return $this->questionpages[$slot]; } /** * Return the grade obtained on a particular question, if the user is permitted * to see it. You must previously have called load_question_states to load the * state data about this question. * * @param int $slot the number used to identify this question within this attempt. * @return string the name of the question. Must be output through format_string. */ public function get_question_name($slot) { return $this->quba->get_question($slot, false)->name; } /** * Return the {@see question_state} that this question is in. * * @param int $slot the number used to identify this question within this attempt. * @return question_state the state this question is in. */ public function get_question_state($slot) { return $this->quba->get_question_state($slot); } /** * Return the grade obtained on a particular question, if the user is permitted * to see it. You must previously have called load_question_states to load the * state data about this question. * * @param int $slot the number used to identify this question within this attempt. * @param bool $showcorrectness Whether right/partial/wrong states should * be distinguished. * @return string the formatted grade, to the number of decimal places specified * by the quiz. */ public function get_question_status($slot, $showcorrectness) { return $this->quba->get_question_state_string($slot, $showcorrectness); } /** * Return the grade obtained on a particular question, if the user is permitted * to see it. You must previously have called load_question_states to load the * state data about this question. * * @param int $slot the number used to identify this question within this attempt. * @param bool $showcorrectness Whether right/partial/wrong states should * be distinguished. * @return string class name for this state. */ public function get_question_state_class($slot, $showcorrectness) { return $this->quba->get_question_state_class($slot, $showcorrectness); } /** * Return the grade obtained on a particular question. * * You must previously have called load_question_states to load the state * data about this question. * * @param int $slot the number used to identify this question within this attempt. * @return string the formatted grade, to the number of decimal places specified by the quiz. */ public function get_question_mark($slot) { return quiz_format_question_grade($this->get_quiz(), $this->quba->get_question_mark($slot)); } /** * Get the time of the most recent action performed on a question. * * @param int $slot the number used to identify this question within this usage. * @return int timestamp. */ public function get_question_action_time($slot) { return $this->quba->get_question_action_time($slot); } /** * Return the question type name for a given slot within the current attempt. * * @param int $slot the number used to identify this question within this attempt. * @return string the question type name. */ public function get_question_type_name($slot) { return $this->quba->get_question($slot, false)->get_type_name(); } /** * Get the time remaining for an in-progress attempt, if the time is short * enough that it would be worth showing a timer. * * @param int $timenow the time to consider as 'now'. * @return int|false the number of seconds remaining for this attempt. * False if there is no limit. */ public function get_time_left_display($timenow) { if ($this->attempt->state != self::IN_PROGRESS) { return false; } return $this->get_access_manager($timenow)->get_time_left_display($this->attempt, $timenow); } /** * Get the time when this attempt was submitted. * * @return int timestamp, or 0 if it has not been submitted yet. */ public function get_submitted_date() { return $this->attempt->timefinish; } /** * If the attempt is in an applicable state, work out the time by which the * student should next do something. * * @return int timestamp by which the student needs to do something. */ public function get_due_date() { $deadlines = []; if ($this->quizobj->get_quiz()->timelimit) { $deadlines[] = $this->attempt->timestart + $this->quizobj->get_quiz()->timelimit; } if ($this->quizobj->get_quiz()->timeclose) { $deadlines[] = $this->quizobj->get_quiz()->timeclose; } if ($deadlines) { $duedate = min($deadlines); } else { return false; } switch ($this->attempt->state) { case self::IN_PROGRESS: return $duedate; case self::OVERDUE: return $duedate + $this->quizobj->get_quiz()->graceperiod; default: throw new coding_exception('Unexpected state: ' . $this->attempt->state); } } // URLs related to this attempt ============================================. /** * Get the URL of this quiz's view.php page. * * @return moodle_url quiz view url. */ public function view_url() { return $this->quizobj->view_url(); } /** * Get the URL to start or continue an attempt. * * @param int|null $slot which question in the attempt to go to after starting (optional). * @param int $page which page in the attempt to go to after starting. * @return moodle_url the URL of this quiz's edit page. Needs to be POSTed to with a cmid parameter. */ public function start_attempt_url($slot = null, $page = -1) { if ($page == -1 && !is_null($slot)) { $page = $this->get_question_page($slot); } else { $page = 0; } return $this->quizobj->start_attempt_url($page); } /** * Generates the title of the attempt page. * * @param int $page the page number (starting with 0) in the attempt. * @return string attempt page title. */ public function attempt_page_title(int $page): string { if ($this->get_num_pages() > 1) { $a = new stdClass(); $a->name = $this->get_quiz_name(); $a->currentpage = $page + 1; $a->totalpages = $this->get_num_pages(); $title = get_string('attempttitlepaged', 'quiz', $a); } else { $title = get_string('attempttitle', 'quiz', $this->get_quiz_name()); } return $title; } /** * Get the URL of a particular page within this attempt. * * @param int|null $slot if specified, the slot number of a specific question to link to. * @param int $page if specified, a particular page to link to. If not given deduced * from $slot, or goes to the first page. * @param int $thispage if not -1, the current page. Will cause links to other things on * this page to be output as only a fragment. * @return moodle_url the URL to continue this attempt. */ public function attempt_url($slot = null, $page = -1, $thispage = -1) { return $this->page_and_question_url('attempt', $slot, $page, false, $thispage); } /** * Generates the title of the summary page. * * @return string summary page title. */ public function summary_page_title(): string { return get_string('attemptsummarytitle', 'quiz', $this->get_quiz_name()); } /** * Get the URL of the summary page of this attempt. * * @return moodle_url the URL of this quiz's summary page. */ public function summary_url() { return new moodle_url('/mod/quiz/summary.php', ['attempt' => $this->attempt->id, 'cmid' => $this->get_cmid()]); } /** * Get the URL to which the attempt data should be submitted. * * @return moodle_url the URL of this quiz's summary page. */ public function processattempt_url() { return new moodle_url('/mod/quiz/processattempt.php'); } /** * Generates the title of the review page. * * @param int $page the page number (starting with 0) in the attempt. * @param bool $showall whether the review page contains the entire attempt on one page. * @return string title of the review page. */ public function review_page_title(int $page, bool $showall = false): string { if (!$showall && $this->get_num_pages() > 1) { $a = new stdClass(); $a->name = $this->get_quiz_name(); $a->currentpage = $page + 1; $a->totalpages = $this->get_num_pages(); $title = get_string('attemptreviewtitlepaged', 'quiz', $a); } else { $title = get_string('attemptreviewtitle', 'quiz', $this->get_quiz_name()); } return $title; } /** * Get the URL of a particular page in the review of this attempt. * * @param int|null $slot indicates which question to link to. * @param int $page if specified, the URL of this particular page of the attempt, otherwise * the URL will go to the first page. If -1, deduce $page from $slot. * @param bool|null $showall if true, the URL will be to review the entire attempt on one page, * and $page will be ignored. If null, a sensible default will be chosen. * @param int $thispage if not -1, the current page. Will cause links to other things on * this page to be output as only a fragment. * @return moodle_url the URL to review this attempt. */ public function review_url($slot = null, $page = -1, $showall = null, $thispage = -1) { return $this->page_and_question_url('review', $slot, $page, $showall, $thispage); } /** * By default, should this script show all questions on one page for this attempt? * * @param string $script the script name, e.g. 'attempt', 'summary', 'review'. * @return bool whether show all on one page should be on by default. */ public function get_default_show_all($script) { return $script === 'review' && count($this->questionpages) < self::MAX_SLOTS_FOR_DEFAULT_REVIEW_SHOW_ALL; } // Bits of content =========================================================. /** * If $reviewoptions->attempt is false, meaning that students can't review this * attempt at the moment, return an appropriate string explaining why. * * @param bool $short if true, return a shorter string. * @return string an appropriate message. */ public function cannot_review_message($short = false) { return $this->quizobj->cannot_review_message( $this->get_attempt_state(), $short, $this->attempt->timefinish); } /** * Initialise the JS etc. required all the questions on a page. * * @param int|string $page a page number, or 'all'. * @param bool $showall if true, forces page number to all. * @return string HTML to output - mostly obsolete, will probably be an empty string. */ public function get_html_head_contributions($page = 'all', $showall = false) { if ($showall) { $page = 'all'; } $result = ''; foreach ($this->get_slots($page) as $slot) { $result .= $this->quba->render_question_head_html($slot); } $result .= question_engine::initialise_js(); return $result; } /** * Initialise the JS etc. required by one question. * * @param int $slot the question slot number. * @return string HTML to output - but this is mostly obsolete. Will probably be an empty string. */ public function get_question_html_head_contributions($slot) { return $this->quba->render_question_head_html($slot) . question_engine::initialise_js(); } /** * Print the HTML for the start new preview button, if the current user * is allowed to see one. * * @return string HTML for the button. */ public function restart_preview_button() { global $OUTPUT; if ($this->is_preview() && $this->is_preview_user()) { return $OUTPUT->single_button(new moodle_url( $this->start_attempt_url(), ['forcenew' => true]), get_string('startnewpreview', 'quiz')); } else { return ''; } } /** * Generate the HTML that displays the question in its current state, with * the appropriate display options. * * @param int $slot identifies the question in the attempt. * @param bool $reviewing is the being printed on an attempt or a review page. * @param renderer $renderer the quiz renderer. * @param moodle_url $thispageurl the URL of the page this question is being printed on. * @return string HTML for the question in its current state. */ public function render_question($slot, $reviewing, renderer $renderer, $thispageurl = null) { if ($this->is_blocked_by_previous_question($slot)) { $placeholderqa = $this->make_blocked_question_placeholder($slot); $displayoptions = $this->get_display_options($reviewing); $displayoptions->manualcomment = question_display_options::HIDDEN; $displayoptions->history = question_display_options::HIDDEN; $displayoptions->readonly = true; $displayoptions->versioninfo = question_display_options::HIDDEN; return html_writer::div($placeholderqa->render($displayoptions, $this->get_question_number($this->get_original_slot($slot))), 'mod_quiz-blocked_question_warning'); } return $this->render_question_helper($slot, $reviewing, $thispageurl, $renderer, null); } /** * Helper used by {@see render_question()} and {@see render_question_at_step()}. * * @param int $slot identifies the question in the attempt. * @param bool $reviewing is the being printed on an attempt or a review page. * @param moodle_url $thispageurl the URL of the page this question is being printed on. * @param renderer $renderer the quiz renderer. * @param int|null $seq the seq number of the past state to display. * @return string HTML fragment. */ protected function render_question_helper($slot, $reviewing, $thispageurl, renderer $renderer, $seq) { $originalslot = $this->get_original_slot($slot); $number = $this->get_question_number($originalslot); $displayoptions = $this->get_display_options_with_edit_link($reviewing, $slot, $thispageurl); if ($slot != $originalslot) { $originalmaxmark = $this->get_question_attempt($slot)->get_max_mark(); $this->get_question_attempt($slot)->set_max_mark($this->get_question_attempt($originalslot)->get_max_mark()); } if ($this->can_question_be_redone_now($slot)) { $displayoptions->extrainfocontent = $renderer->redo_question_button( $slot, $displayoptions->readonly); } if ($displayoptions->history && $displayoptions->questionreviewlink) { $links = $this->links_to_other_redos($slot, $displayoptions->questionreviewlink); if ($links) { $displayoptions->extrahistorycontent = html_writer::tag('p', get_string('redoesofthisquestion', 'quiz', $renderer->render($links))); } } if ($seq === null) { $output = $this->quba->render_question($slot, $displayoptions, $number); } else { $output = $this->quba->render_question_at_step($slot, $seq, $displayoptions, $number); } if ($slot != $originalslot) { $this->get_question_attempt($slot)->set_max_mark($originalmaxmark); } return $output; } /** * Create a fake question to be displayed in place of a question that is blocked * until the previous question has been answered. * * @param int $slot int slot number of the question to replace. * @return question_attempt the placeholder question attempt. */ protected function make_blocked_question_placeholder($slot) { $replacedquestion = $this->get_question_attempt($slot)->get_question(false); question_bank::load_question_definition_classes('description'); $question = new qtype_description_question(); $question->id = $replacedquestion->id; $question->category = null; $question->parent = 0; $question->qtype = question_bank::get_qtype('description'); $question->name = ''; $question->questiontext = get_string('questiondependsonprevious', 'quiz'); $question->questiontextformat = FORMAT_HTML; $question->generalfeedback = ''; $question->defaultmark = $this->quba->get_question_max_mark($slot); $question->length = $replacedquestion->length; $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; $placeholderqa = new question_attempt($question, $this->quba->get_id(), null, $this->quba->get_question_max_mark($slot)); $placeholderqa->set_slot($slot); $placeholderqa->start($this->get_quiz()->preferredbehaviour, 1); $placeholderqa->set_flagged($this->is_question_flagged($slot)); return $placeholderqa; } /** * Like {@see render_question()} but displays the question at the past step * indicated by $seq, rather than showing the latest step. * * @param int $slot the slot number of a question in this quiz attempt. * @param int $seq the seq number of the past state to display. * @param bool $reviewing is the being printed on an attempt or a review page. * @param renderer $renderer the quiz renderer. * @param moodle_url $thispageurl the URL of the page this question is being printed on. * @return string HTML for the question in its current state. */ public function render_question_at_step($slot, $seq, $reviewing, renderer $renderer, $thispageurl = null) { return $this->render_question_helper($slot, $reviewing, $thispageurl, $renderer, $seq); } /** * Wrapper round print_question from lib/questionlib.php. * * @param int $slot the id of a question in this quiz attempt. * @return string HTML of the question. */ public function render_question_for_commenting($slot) { $options = $this->get_display_options(true); $options->generalfeedback = question_display_options::HIDDEN; $options->manualcomment = question_display_options::EDITABLE; return $this->quba->render_question($slot, $options, $this->get_question_number($slot)); } /** * Check whether access should be allowed to a particular file. * * @param int $slot the slot of a question in this quiz attempt. * @param bool $reviewing is the being printed on an attempt or a review page. * @param int $contextid the file context id from the request. * @param string $component the file component from the request. * @param string $filearea the file area from the request. * @param array $args extra part components from the request. * @param bool $forcedownload whether to force download. * @return bool true if the file can be accessed. */ public function check_file_access($slot, $reviewing, $contextid, $component, $filearea, $args, $forcedownload) { $options = $this->get_display_options($reviewing); // Check permissions - warning there is similar code in review.php and // reviewquestion.php. If you change on, change them all. if ($reviewing && $this->is_own_attempt() && !$options->attempt) { return false; } if ($reviewing && !$this->is_own_attempt() && !$this->is_review_allowed()) { return false; } return $this->quba->check_file_access($slot, $options, $component, $filearea, $args, $forcedownload); } /** * Get the navigation panel object for this attempt. * * @param renderer $output the quiz renderer to use to output things. * @param string $panelclass The type of panel, navigation_panel_attempt::class or navigation_panel_review::class * @param int $page the current page number. * @param bool $showall whether we are showing the whole quiz on one page. (Used by review.php.) * @return block_contents the requested object. */ public function get_navigation_panel(renderer $output, $panelclass, $page, $showall = false) { $panel = new $panelclass($this, $this->get_display_options(true), $page, $showall); $bc = new block_contents(); $bc->attributes['id'] = 'mod_quiz_navblock'; $bc->attributes['role'] = 'navigation'; $bc->title = get_string('quiznavigation', 'quiz'); $bc->content = $output->navigation_panel($panel); return $bc; } /** * Return an array of variant URLs to other attempts at this quiz. * * The $url passed in must contain an attempt parameter. * * The {@see links_to_other_attempts} object returned contains an * array with keys that are the attempt number, 1, 2, 3. * The array values are either a {@see moodle_url} with the attempt parameter * updated to point to the attempt id of the other attempt, or null corresponding * to the current attempt number. * * @param moodle_url $url a URL. * @return links_to_other_attempts|bool containing array int => null|moodle_url. * False if none. */ public function links_to_other_attempts(moodle_url $url) { $attempts = quiz_get_user_attempts($this->get_quiz()->id, $this->attempt->userid, 'all'); if (count($attempts) <= 1) { return false; } $links = new links_to_other_attempts(); foreach ($attempts as $at) { if ($at->id == $this->attempt->id) { $links->links[$at->attempt] = null; } else { $links->links[$at->attempt] = new moodle_url($url, ['attempt' => $at->id]); } } return $links; } /** * Return an array of variant URLs to other redos of the question in a particular slot. * * The $url passed in must contain a slot parameter. * * The {@see links_to_other_attempts} object returned contains an * array with keys that are the redo number, 1, 2, 3. * The array values are either a {@see moodle_url} with the slot parameter * updated to point to the slot that has that redo of this question; or null * corresponding to the redo identified by $slot. * * @param int $slot identifies a question in this attempt. * @param moodle_url $baseurl the base URL to modify to generate each link. * @return links_to_other_attempts|null containing array int => null|moodle_url, * or null if the question in this slot has not been redone. */ public function links_to_other_redos($slot, moodle_url $baseurl) { $originalslot = $this->get_original_slot($slot); $qas = $this->all_question_attempts_originally_in_slot($originalslot); if (count($qas) <= 1) { return null; } $links = new links_to_other_attempts(); $index = 1; foreach ($qas as $qa) { if ($qa->get_slot() == $slot) { $links->links[$index] = null; } else { $url = new moodle_url($baseurl, ['slot' => $qa->get_slot()]); $links->links[$index] = new action_link($url, $index, new popup_action('click', $url, 'reviewquestion', ['width' => 450, 'height' => 650]), ['title' => get_string('reviewresponse', 'question')]); } $index++; } return $links; } // Methods for processing ==================================================. /** * Check this attempt, to see if there are any state transitions that should * happen automatically. This function will update the attempt checkstatetime. * @param int $timestamp the timestamp that should be stored as the modified * @param bool $studentisonline is the student currently interacting with Moodle? */ public function handle_if_time_expired($timestamp, $studentisonline) { $timeclose = $this->get_access_manager($timestamp)->get_end_time($this->attempt); if ($timeclose === false || $this->is_preview()) { $this->update_timecheckstate(null); return; // No time limit. } if ($timestamp < $timeclose) { $this->update_timecheckstate($timeclose); return; // Time has not yet expired. } // If the attempt is already overdue, look to see if it should be abandoned ... if ($this->attempt->state == self::OVERDUE) { $timeoverdue = $timestamp - $timeclose; $graceperiod = $this->quizobj->get_quiz()->graceperiod; if ($timeoverdue >= $graceperiod) { $this->process_abandon($timestamp, $studentisonline); } else { // Overdue time has not yet expired. $this->update_timecheckstate($timeclose + $graceperiod); } return; // ... and we are done. } if ($this->attempt->state != self::IN_PROGRESS) { $this->update_timecheckstate(null); return; // Attempt is already in a final state. } // Otherwise, we were in quiz_attempt::IN_PROGRESS, and time has now expired. // Transition to the appropriate state. switch ($this->quizobj->get_quiz()->overduehandling) { case 'autosubmit': $this->process_submit($timestamp, false, $studentisonline ? $timestamp : $timeclose, $studentisonline); $this->process_grade_submission($studentisonline ? $timestamp : $timeclose); return; case 'graceperiod': $this->process_going_overdue($timestamp, $studentisonline); return; case 'autoabandon': $this->process_abandon($timestamp, $studentisonline); return; } // This is an overdue attempt with no overdue handling defined, so just abandon. $this->process_abandon($timestamp, $studentisonline); } /** * Process all the actions that were submitted as part of the current request. * * @param int $timestamp the timestamp that should be stored as the modified. * time in the database for these actions. If null, will use the current time. * @param bool $becomingoverdue * @param array|null $simulatedresponses If not null, then we are testing, and this is an array of simulated data. * There are two formats supported here, for historical reasons. The newer approach is to pass an array created by * {@see core_question_generator::get_simulated_post_data_for_questions_in_usage()}. * the second is to pass an array slot no => contains arrays representing student * responses which will be passed to {@see question_definition::prepare_simulated_post_data()}. * This second method will probably get deprecated one day. */ public function process_submitted_actions($timestamp, $becomingoverdue = false, $simulatedresponses = null) { global $DB; $transaction = $DB->start_delegated_transaction(); if ($simulatedresponses !== null) { if (is_int(key($simulatedresponses))) { // Legacy approach. Should be removed one day. $simulatedpostdata = $this->quba->prepare_simulated_post_data($simulatedresponses); } else { $simulatedpostdata = $simulatedresponses; } } else { $simulatedpostdata = null; } $this->quba->process_all_actions($timestamp, $simulatedpostdata); question_engine::save_questions_usage_by_activity($this->quba); $this->attempt->timemodified = $timestamp; if ($this->attempt->state == self::FINISHED) { $this->attempt->sumgrades = $this->quba->get_total_mark(); } if ($becomingoverdue) { $this->process_going_overdue($timestamp, true); } else { $DB->update_record('quiz_attempts', $this->attempt); } if (!$this->is_preview() && $this->attempt->state == self::FINISHED) { $this->recompute_final_grade(); } $transaction->allow_commit(); } /** * Replace a question in an attempt with a new attempt at the same question. * * Well, for randomised questions, it won't be the same question, it will be * a different randomly selected pick from the available question. * * @param int $slot the question to restart. * @param int $timestamp the timestamp to record for this action. */ public function process_redo_question($slot, $timestamp) { global $DB; if (!$this->can_question_be_redone_now($slot)) { throw new coding_exception('Attempt to restart the question in slot ' . $slot . ' when it is not in a state to be restarted.'); } $qubaids = new \mod_quiz\question\qubaids_for_users_attempts( $this->get_quizid(), $this->get_userid(), 'all', true); $transaction = $DB->start_delegated_transaction(); // Add the question to the usage. It is important we do this before we choose a variant. $newquestionid = qbank_helper::choose_question_for_redo($this->get_quizid(), $this->get_quizobj()->get_context(), $this->slots[$slot]->id, $qubaids); $newquestion = question_bank::load_question($newquestionid, $this->get_quiz()->shuffleanswers); $newslot = $this->quba->add_question_in_place_of_other($slot, $newquestion); // Choose the variant. if ($newquestion->get_num_variants() == 1) { $variant = 1; } else { $variantstrategy = new \core_question\engine\variants\least_used_strategy( $this->quba, $qubaids); $variant = $variantstrategy->choose_variant($newquestion->get_num_variants(), $newquestion->get_variants_selection_seed()); } // Start the question. $this->quba->start_question($slot, $variant); $this->quba->set_max_mark($newslot, 0); $this->quba->set_question_attempt_metadata($newslot, 'originalslot', $slot); question_engine::save_questions_usage_by_activity($this->quba); $this->fire_attempt_question_restarted_event($slot, $newquestion->id); $transaction->allow_commit(); } /** * Process all the autosaved data that was part of the current request. * * @param int $timestamp the timestamp that should be stored as the modified. * time in the database for these actions. If null, will use the current time. */ public function process_auto_save($timestamp) { global $DB; $transaction = $DB->start_delegated_transaction(); $this->quba->process_all_autosaves($timestamp); question_engine::save_questions_usage_by_activity($this->quba); $this->fire_attempt_autosaved_event(); $transaction->allow_commit(); } /** * Update the flagged state for all question_attempts in this usage, if their * flagged state was changed in the request. */ public function save_question_flags() { global $DB; $transaction = $DB->start_delegated_transaction(); $this->quba->update_question_flags(); question_engine::save_questions_usage_by_activity($this->quba); $transaction->allow_commit(); } /** * Submit the attempt. * * The separate $timefinish argument should be used when the quiz attempt * is being processed asynchronously (for example when cron is submitting * attempts where the time has expired). * * @param int $timestamp the time to record as last modified time. * @param bool $processsubmitted if true, and question responses in the current * POST request are stored to be graded, before the attempt is finished. * @param ?int $timefinish if set, use this as the finish time for the attempt. * (otherwise use $timestamp as the finish time as well). * @param bool $studentisonline is the student currently interacting with Moodle? * @deprecated since Moodle 5.0 MDL-68806 use process_submit() and process_grade_submission() instead * @todo Final deprecation in Moodle 6.0 MDL-80956 */ public function process_finish($timestamp, $processsubmitted, $timefinish = null, $studentisonline = false) { debugging('quiz_attempt::process_finish is deprecated. Please use quiz_attempt::process_submit to store ' . 'answers and mark an attempt submitted, and quiz_attempt::process_grade_submission to do automatic grading.'); $this->process_submit($timestamp, $processsubmitted, $timefinish, $studentisonline); $this->process_grade_submission($timefinish ?? $timestamp); } /** * Submit the attempt. * * The separate $timefinish argument should be used when the quiz attempt * is being processed asynchronously (for example when cron is submitting * attempts where the time has expired). * * @param int $timestamp the time to record as last modified time. * @param bool $processsubmitted if true, and question responses in the current * POST request are stored to be graded, before the attempt is finished. * @param ?int $timefinish if set, use this as the finish time for the attempt. * (otherwise use $timestamp as the finish time as well). * @param bool $studentisonline is the student currently interacting with Moodle? */ public function process_submit( int $timestamp, bool $processsubmitted, ?int $timefinish = null, bool $studentisonline = false ): void { global $DB; $transaction = $DB->start_delegated_transaction(); if ($processsubmitted) { $this->quba->process_all_actions($timestamp); } question_engine::save_questions_usage_by_activity($this->quba); $originalattempt = clone $this->attempt; $this->attempt->timemodified = $timestamp; $this->attempt->timefinish = $timefinish ?? $timestamp; $this->attempt->state = self::SUBMITTED; $this->attempt->timecheckstate = null; $DB->update_record('quiz_attempts', $this->attempt); if (!$this->is_preview()) { // Trigger event. $this->fire_state_transition_event('\mod_quiz\event\attempt_submitted', $timestamp, $studentisonline); \core\hook\manager::get_instance()->dispatch(new attempt_state_changed($originalattempt, $this->attempt)); } $transaction->allow_commit(); } /** * Perform automatic grading for a submitted attempt. * * @param int $timestamp the time to record as last modified time. */ public function process_grade_submission(int $timestamp): void { global $DB; $transaction = $DB->start_delegated_transaction(); $this->quba->finish_all_questions($timestamp); question_engine::save_questions_usage_by_activity($this->quba); $originalattempt = clone $this->attempt; $this->attempt->timemodified = $timestamp; $this->attempt->sumgrades = $this->quba->get_total_mark(); $this->attempt->state = self::FINISHED; if ( !$this->requires_manual_grading() || !has_capability('mod/quiz:emailnotifyattemptgraded', $this->get_quizobj()->get_context(), $this->get_userid()) ) { $this->attempt->gradednotificationsenttime = $timestamp; } $DB->update_record('quiz_attempts', $this->attempt); if (!$this->is_preview()) { $this->recompute_final_grade(); // Trigger event. $this->fire_state_transition_event('\mod_quiz\event\attempt_graded', $timestamp, false); di::get(hook\manager::class)->dispatch(new attempt_state_changed($originalattempt, $this->attempt)); // Tell any access rules that care that the attempt is over. $this->get_access_manager($timestamp)->current_attempt_finished(); } $transaction->allow_commit(); } /** * Update this attempt timecheckstate if necessary. * * @param int|null $time the timestamp to set. */ public function update_timecheckstate($time) { global $DB; if ($this->attempt->timecheckstate !== $time) { $this->attempt->timecheckstate = $time; $DB->set_field('quiz_attempts', 'timecheckstate', $time, ['id' => $this->attempt->id]); } } /** * Needs to be called after this attempt's grade is changed, to update the overall quiz grade. */ protected function recompute_final_grade(): void { $this->quizobj->get_grade_calculator()->recompute_final_grade($this->get_userid()); } /** * Mark this attempt as now overdue. * * @param int $timestamp the time to deem as now. * @param bool $studentisonline is the student currently interacting with Moodle? */ public function process_going_overdue($timestamp, $studentisonline) { global $DB; $originalattempt = clone $this->attempt; $transaction = $DB->start_delegated_transaction(); $this->attempt->timemodified = $timestamp; $this->attempt->state = self::OVERDUE; // If we knew the attempt close time, we could compute when the graceperiod ends. // Instead, we'll just fix it up through cron. $this->attempt->timecheckstate = $timestamp; $DB->update_record('quiz_attempts', $this->attempt); $this->fire_state_transition_event('\mod_quiz\event\attempt_becameoverdue', $timestamp, $studentisonline); di::get(hook\manager::class)->dispatch(new attempt_state_changed($originalattempt, $this->attempt)); $transaction->allow_commit(); quiz_send_overdue_message($this); } /** * Mark this attempt as abandoned. * * @param int $timestamp the time to deem as now. * @param bool $studentisonline is the student currently interacting with Moodle? */ public function process_abandon($timestamp, $studentisonline) { global $DB; $originalattempt = clone $this->attempt; $transaction = $DB->start_delegated_transaction(); $this->attempt->timemodified = $timestamp; $this->attempt->state = self::ABANDONED; $this->attempt->timecheckstate = null; $DB->update_record('quiz_attempts', $this->attempt); $this->fire_state_transition_event('\mod_quiz\event\attempt_abandoned', $timestamp, $studentisonline); di::get(hook\manager::class)->dispatch(new attempt_state_changed($originalattempt, $this->attempt)); $transaction->allow_commit(); } /** * This method takes an attempt in the 'Never submitted' state, and reopens it. * * If, for this student, time has not expired (perhaps, because an override has * been added, then the attempt is left open. Otherwise, it is immediately submitted * for grading. * * @param int $timestamp the time to deem as now. */ public function process_reopen_abandoned($timestamp) { global $DB; // Verify that things are as we expect. if ($this->get_state() != self::ABANDONED) { throw new coding_exception('Can only reopen an attempt that was never submitted.'); } $originalattempt = clone $this->attempt; $transaction = $DB->start_delegated_transaction(); $this->attempt->timemodified = $timestamp; $this->attempt->state = self::IN_PROGRESS; $this->attempt->timecheckstate = null; $DB->update_record('quiz_attempts', $this->attempt); $this->fire_state_transition_event('\mod_quiz\event\attempt_reopened', $timestamp, false); di::get(hook\manager::class)->dispatch(new attempt_state_changed($originalattempt, $this->attempt)); $timeclose = $this->get_access_manager($timestamp)->get_end_time($this->attempt); if ($timeclose && $timestamp > $timeclose) { $this->process_submit($timestamp, false, $timeclose); $this->process_grade_submission($timeclose); } $transaction->allow_commit(); } /** * Fire a state transition event. * * @param string $eventclass the event class name. * @param int $timestamp the timestamp to include in the event. * @param bool $studentisonline is the student currently interacting with Moodle? */ protected function fire_state_transition_event($eventclass, $timestamp, $studentisonline) { global $USER; $quizrecord = $this->get_quiz(); $params = [ 'context' => $this->get_quizobj()->get_context(), 'courseid' => $this->get_courseid(), 'objectid' => $this->attempt->id, 'relateduserid' => $this->attempt->userid, 'other' => [ 'submitterid' => CLI_SCRIPT ? null : $USER->id, 'quizid' => $quizrecord->id, 'studentisonline' => $studentisonline ] ]; $event = $eventclass::create($params); $event->add_record_snapshot('quiz', $this->get_quiz()); $event->add_record_snapshot('quiz_attempts', $this->get_attempt()); $event->trigger(); } // Private methods =========================================================. /** * Get a URL for a particular question on a particular page of the quiz. * Used by {@see attempt_url()} and {@see review_url()}. * * @param string $script e.g. 'attempt' or 'review'. Used in the URL like /mod/quiz/$script.php. * @param int $slot identifies the specific question on the page to jump to. * 0 to just use the $page parameter. * @param int $page -1 to look up the page number from the slot, otherwise * the page number to go to. * @param bool|null $showall if true, return a URL with showall=1, and not page number. * if null, then an intelligent default will be chosen. * @param int $thispage the page we are currently on. Links to questions on this * page will just be a fragment #q123. -1 to disable this. * @return moodle_url The requested URL. */ protected function page_and_question_url($script, $slot, $page, $showall, $thispage) { $defaultshowall = $this->get_default_show_all($script); if ($showall === null && ($page == 0 || $page == -1)) { $showall = $defaultshowall; } // Fix up $page. if ($page == -1) { if ($slot !== null && !$showall) { $page = $this->get_question_page($slot); } else { $page = 0; } } if ($showall) { $page = 0; } // Add a fragment to scroll down to the question. $fragment = ''; if ($slot !== null) { if ($slot == reset($this->pagelayout[$page]) && $thispage != $page) { // Changing the page, go to top. $fragment = '#'; } else { // Link to the question container. $qa = $this->get_question_attempt($slot); $fragment = '#' . $qa->get_outer_question_div_unique_id(); } } // Work out the correct start to the URL. if ($thispage == $page) { return new moodle_url($fragment); } else { $url = new moodle_url('/mod/quiz/' . $script . '.php' . $fragment, ['attempt' => $this->attempt->id, 'cmid' => $this->get_cmid()]); if ($page == 0 && $showall != $defaultshowall) { $url->param('showall', (int) $showall); } else if ($page > 0) { $url->param('page', $page); } return $url; } } /** * Process responses during an attempt at a quiz. * * @param int $timenow time when the processing started. * @param bool $finishattempt whether to finish the attempt or not. * @param bool $timeup true if form was submitted by timer. * @param int $thispage current page number. * @return string the attempt state once the data has been processed. * @since Moodle 3.1 */ public function process_attempt($timenow, $finishattempt, $timeup, $thispage) { global $DB; $transaction = $DB->start_delegated_transaction(); // Get key times. $accessmanager = $this->get_access_manager($timenow); $timeclose = $accessmanager->get_end_time($this->get_attempt()); $graceperiodmin = get_config('quiz', 'graceperiodmin'); // Don't enforce timeclose for previews. if ($this->is_preview()) { $timeclose = false; } // Check where we are in relation to the end time, if there is one. $toolate = false; if ($timeclose !== false) { if ($timenow > $timeclose - QUIZ_MIN_TIME_TO_CONTINUE) { // If there is only a very small amount of time left, there is no point trying // to show the student another page of the quiz. Just finish now. $timeup = true; if ($timenow > $timeclose + $graceperiodmin) { $toolate = true; } } else { // If time is not close to expiring, then ignore the client-side timer's opinion // about whether time has expired. This can happen if the time limit has changed // since the student's previous interaction. $timeup = false; } } // If time is running out, trigger the appropriate action. $becomingoverdue = false; $becomingabandoned = false; if ($timeup) { if ($this->get_quiz()->overduehandling === 'graceperiod') { if ($timenow > $timeclose + $this->get_quiz()->graceperiod + $graceperiodmin) { // Grace period has run out. $finishattempt = true; $becomingabandoned = true; } else { $becomingoverdue = true; } } else { $finishattempt = true; } } if (!$finishattempt) { // Just process the responses for this page and go to the next page. if (!$toolate) { try { $this->process_submitted_actions($timenow, $becomingoverdue); $this->fire_attempt_updated_event(); } catch (question_out_of_sequence_exception $e) { throw new moodle_exception('submissionoutofsequencefriendlymessage', 'question', $this->attempt_url(null, $thispage)); } 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', $this->attempt_url(null, $thispage), $e->getMessage(), $debuginfo); } if (!$becomingoverdue) { foreach ($this->get_slots() as $slot) { if (optional_param('redoslot' . $slot, false, PARAM_BOOL)) { $this->process_redo_question($slot, $timenow); } } } } else { // The student is too late. $this->process_going_overdue($timenow, true); } $transaction->allow_commit(); return $becomingoverdue ? self::OVERDUE : self::IN_PROGRESS; } // Update the quiz attempt record. try { if ($becomingabandoned) { $this->process_abandon($timenow, true); } else { if (!$toolate || $this->get_quiz()->overduehandling === 'graceperiod') { // Normally, we record the accurate finish time when the student is online. $finishtime = $timenow; } else { // But, if there is no grade period, and the final responses were too // late to be processed, record the close time, to reduce confusion. $finishtime = $timeclose; } $this->process_submit($timenow, !$toolate, $finishtime, true); $this->process_grade_submission($finishtime); } } catch (question_out_of_sequence_exception $e) { throw new moodle_exception('submissionoutofsequencefriendlymessage', 'question', $this->attempt_url(null, $thispage)); } 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', $this->attempt_url(null, $thispage), $e->getMessage(), $debuginfo); } // Send the user to the review page. $transaction->allow_commit(); return $becomingabandoned ? self::ABANDONED : self::FINISHED; } /** * Check a page read access to see if is an out of sequence access. * * If allownext is set then we also check whether access to the page * after the current one should be permitted. * * @param int $page page number. * @param bool $allownext in case of a sequential navigation, can we go to next page ? * @return boolean false is an out of sequence access, true otherwise. * @since Moodle 3.1 */ public function check_page_access(int $page, bool $allownext = true): bool { if ($this->get_navigation_method() != QUIZ_NAVMETHOD_SEQ) { return true; } // Sequential access: allow access to the summary, current page or next page. // Or if the user review his/her attempt, see MDLQA-1523. return $page == -1 || $page == $this->get_currentpage() || $allownext && ($page == $this->get_currentpage() + 1); } /** * Update attempt page. * * @param int $page page number. * @return boolean true if everything was ok, false otherwise (out of sequence access). * @since Moodle 3.1 */ public function set_currentpage($page) { global $DB; if ($this->check_page_access($page)) { $DB->set_field('quiz_attempts', 'currentpage', $page, ['id' => $this->get_attemptid()]); return true; } return false; } /** * Trigger the attempt_viewed event. * * @since Moodle 3.1 */ public function fire_attempt_viewed_event() { $params = [ 'objectid' => $this->get_attemptid(), 'relateduserid' => $this->get_userid(), 'courseid' => $this->get_courseid(), 'context' => $this->get_context(), 'other' => [ 'quizid' => $this->get_quizid(), 'page' => $this->get_currentpage() ] ]; $event = \mod_quiz\event\attempt_viewed::create($params); $event->add_record_snapshot('quiz_attempts', $this->get_attempt()); $event->trigger(); } /** * Trigger the attempt_updated event. * * @return void */ public function fire_attempt_updated_event(): void { $params = [ 'objectid' => $this->get_attemptid(), 'relateduserid' => $this->get_userid(), 'courseid' => $this->get_courseid(), 'context' => $this->get_context(), 'other' => [ 'quizid' => $this->get_quizid(), 'page' => $this->get_currentpage() ] ]; $event = \mod_quiz\event\attempt_updated::create($params); $event->add_record_snapshot('quiz_attempts', $this->get_attempt()); $event->trigger(); } /** * Trigger the attempt_autosaved event. * * @return void */ public function fire_attempt_autosaved_event(): void { $params = [ 'objectid' => $this->get_attemptid(), 'relateduserid' => $this->get_userid(), 'courseid' => $this->get_courseid(), 'context' => $this->get_context(), 'other' => [ 'quizid' => $this->get_quizid(), 'page' => $this->get_currentpage() ] ]; $event = \mod_quiz\event\attempt_autosaved::create($params); $event->add_record_snapshot('quiz_attempts', $this->get_attempt()); $event->trigger(); } /** * Trigger the attempt_question_restarted event. * * @param int $slot Slot number * @param int $newquestionid New question id. * @return void */ public function fire_attempt_question_restarted_event(int $slot, int $newquestionid): void { $params = [ 'objectid' => $this->get_attemptid(), 'relateduserid' => $this->get_userid(), 'courseid' => $this->get_courseid(), 'context' => $this->get_context(), 'other' => [ 'quizid' => $this->get_quizid(), 'page' => $this->get_currentpage(), 'slot' => $slot, 'newquestionid' => $newquestionid ] ]; $event = \mod_quiz\event\attempt_question_restarted::create($params); $event->add_record_snapshot('quiz_attempts', $this->get_attempt()); $event->trigger(); } /** * Trigger the attempt_summary_viewed event. * * @since Moodle 3.1 */ public function fire_attempt_summary_viewed_event() { $params = [ 'objectid' => $this->get_attemptid(), 'relateduserid' => $this->get_userid(), 'courseid' => $this->get_courseid(), 'context' => $this->get_context(), 'other' => [ 'quizid' => $this->get_quizid() ] ]; $event = \mod_quiz\event\attempt_summary_viewed::create($params); $event->add_record_snapshot('quiz_attempts', $this->get_attempt()); $event->trigger(); } /** * Trigger the attempt_reviewed event. * * @since Moodle 3.1 */ public function fire_attempt_reviewed_event() { $params = [ 'objectid' => $this->get_attemptid(), 'relateduserid' => $this->get_userid(), 'courseid' => $this->get_courseid(), 'context' => $this->get_context(), 'other' => [ 'quizid' => $this->get_quizid() ] ]; $event = \mod_quiz\event\attempt_reviewed::create($params); $event->add_record_snapshot('quiz_attempts', $this->get_attempt()); $event->trigger(); } /** * Trigger the attempt manual grading completed event. */ public function fire_attempt_manual_grading_completed_event() { $params = [ 'objectid' => $this->get_attemptid(), 'relateduserid' => $this->get_userid(), 'courseid' => $this->get_courseid(), 'context' => $this->get_context(), 'other' => [ 'quizid' => $this->get_quizid() ] ]; $event = \mod_quiz\event\attempt_manual_grading_completed::create($params); $event->add_record_snapshot('quiz_attempts', $this->get_attempt()); $event->trigger(); } /** * Update the timemodifiedoffline attempt field. * * This function should be used only when web services are being used. * * @param int $time time stamp. * @return boolean false if the field is not updated because web services aren't being used. * @since Moodle 3.2 */ public function set_offline_modified_time($time) { // Update the timemodifiedoffline field only if web services are being used. if (WS_SERVER) { $this->attempt->timemodifiedoffline = $time; return true; } return false; } /** * Get the total number of unanswered questions in the attempt. * * @return int */ public function get_number_of_unanswered_questions(): int { $totalunanswered = 0; foreach ($this->get_slots() as $slot) { if (!$this->is_real_question($slot)) { continue; } $questionstate = $this->get_question_state($slot); if ($questionstate == question_state::$todo || $questionstate == question_state::$invalid) { $totalunanswered++; } } return $totalunanswered; } /** * If any questions in this attempt have changed, update the attempts. * * For now, this should only be done for previews. * * When we update the question, we keep the same question (in the case of random questions) * and the same variant (if this question has variants). If possible, we use regrade to * preserve any interaction that has been had with this question (e.g. a saved answer) but * if that is not possible, we put in a newly started attempt. */ public function update_questions_to_new_version_if_changed(): void { global $DB; $versioninformation = qbank_helper::get_version_information_for_questions_in_attempt( $this->attempt, $this->get_context()); // Retrieve all slots (questions) in the quiz attempt. $slots = $this->get_slots(); // Check if all slots have corresponding version information. foreach ($slots as $slot) { if (!isset($versioninformation[$slot])) { $this->handle_missing_question_attempt(); } } $anychanges = false; foreach ($versioninformation as $slotinformation) { if ($slotinformation->currentquestionid == $slotinformation->newquestionid) { continue; } $anychanges = true; $slot = $slotinformation->questionattemptslot; $newquestion = question_bank::load_question($slotinformation->newquestionid); if (empty($this->quba->validate_can_regrade_with_other_version($slot, $newquestion))) { // We can use regrade to replace the question while preserving any existing state. $finished = $this->get_attempt()->state == self::FINISHED; $this->quba->regrade_question($slot, $finished, null, $newquestion); } else { // So much has changed, we have to replace the question with a new attempt. $oldvariant = $this->get_question_attempt($slot)->get_variant(); $slot = $this->quba->add_question_in_place_of_other($slot, $newquestion, null, false); $this->quba->start_question($slot, $oldvariant); } } if ($anychanges) { question_engine::save_questions_usage_by_activity($this->quba); if ($this->attempt->state == self::FINISHED) { $this->attempt->sumgrades = $this->quba->get_total_mark(); $DB->update_record('quiz_attempts', $this->attempt); $this->recompute_final_grade(); } } } /** * Handle the case where a question in an attempt has been deleted. */ private function handle_missing_question_attempt(): void { quiz_delete_attempt($this->attempt, $this->get_quiz()); $continuelink = new moodle_url('/mod/quiz/view.php', ['id' => $this->get_cmid()]); if (has_capability('mod/quiz:preview', context_module::instance($this->get_cmid()))) { throw new moodle_exception('attempterrorcontentchange', 'quiz', $continuelink); } else { throw new moodle_exception('attempterrorcontentchangeforuser', 'quiz', $continuelink); } } } PK9B\  +classes/output/navigation_panel_attempt.phpnu[. namespace mod_quiz\output; use html_writer; /** * Specialisation of {@see navigation_panel_base} for the attempt quiz page. * * This class is not currently renderable or templatable, but it probably should be in the future, * which is why it is already in the output namespace. * * @package mod_quiz * @category output * @copyright 2008 Tim Hunt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class navigation_panel_attempt extends navigation_panel_base { public function get_question_url($slot) { if ($this->attemptobj->can_navigate_to($slot)) { return $this->attemptobj->attempt_url($slot, -1, $this->page); } else { return null; } } public function render_before_button_bits(renderer $output) { return html_writer::tag('div', get_string('navnojswarning', 'quiz'), ['id' => 'quiznojswarning']); } public function render_end_bits(renderer $output) { if ($this->page == -1) { // Don't link from the summary page to itself. return ''; } // We create a hidden div with an information message in order for the student // to known when their answers have been auto-saved. $html = html_writer::div(get_string('lastautosave', 'quiz', '-'), 'autosave_info', ['hidden' => 'hidden']); $html .= html_writer::link($this->attemptobj->summary_url(), get_string('endtest', 'quiz'), ['class' => 'endtestlink aalink']) . $this->render_restart_preview_link($output); return $html; } } PK9B\G>bA7A7.classes/output/attempt_summary_information.phpnu[. namespace mod_quiz\output; use action_link; use core\output\named_templatable; use html_writer; use mod_quiz\grade_calculator; use mod_quiz\output\grades\grade_out_of; use mod_quiz\quiz_attempt; use moodle_url; use mod_quiz\question\display_options; use question_display_options; use renderable; use renderer_base; use stdClass; use user_picture; /** * A summary of a single quiz attempt for rendering. * * This is used in places like * - at the top of the review attempt page (review.php) * - at the top of the review single question page (reviewquestion.php) * - on the quiz entry page (view.php). * * @package mod_quiz * @copyright 2024 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class attempt_summary_information implements renderable, named_templatable { /** @var array[] The rows of summary data. {@see add_item()} should make the structure clear. */ protected array $summarydata = []; /** @var string The caption for attempt summary table. */ protected string $caption = ''; /** * Add an item to the summary. * * @param string $shortname unique identifier of this item (not displayed). * @param string|renderable $title the title of this item. * @param string|renderable $content the content of this item. */ public function add_item(string $shortname, string|renderable $title, string|renderable $content): void { $this->summarydata[$shortname] = [ 'title' => $title, 'content' => $content, ]; } /** * Set the caption for the summary table. * * @param string $caption */ public function set_caption(string $caption): void { $this->caption = $caption; } /** * Add an item to the summary just before the given item. * * If that item is not present, then add as the first item. * * @param string $shortname unique identifier of this item (not displayed). * @param string|renderable $title the title of this item. * @param string|renderable $content the content of this item. * @param string $addbefore identifier of the other item to add this before. */ public function add_item_before( string $shortname, string|renderable $title, string|renderable $content, string $addbefore, ): void { $position = array_search($addbefore, array_keys($this->summarydata)); if ($position !== false) { $this->insert_new_item_at_position($shortname, $title, $content, $position); } else { $this->insert_new_item_at_position($shortname, $title, $content, 0); } } /** * Add an item to the summary just after the given item. * * If that item is not present, then just add at the end. * * @param string $shortname unique identifier of this item (not displayed). * @param string|renderable $title the title of this item. * @param string|renderable $content the content of this item. * @param string $addafter identifier of the other item to add this before. */ public function add_item_after( string $shortname, string|renderable $title, string|renderable $content, string $addafter, ): void { $position = array_search($addafter, array_keys($this->summarydata)); if ($position !== false) { $this->insert_new_item_at_position($shortname, $title, $content, $position + 1); } else { $this->add_item($shortname, $title, $content); } } /** * Add an item to the summary just before the given position. * * @param string $shortname unique identifier of this item (not displayed). * @param string|renderable $title the title of this item. * @param string|renderable $content the content of this item. * @param int $position Numerical position to insert the item at. 0 means first. */ protected function insert_new_item_at_position( string $shortname, string|renderable $title, string|renderable $content, int $position, ) { $this->summarydata = array_merge( array_slice($this->summarydata, 0, $position), [$shortname => ['title' => $title, 'content' => $content]], array_slice($this->summarydata, $position, count($this->summarydata)), ); } /** * Remove an item, if present. * * @param string $shortname */ public function remove_item(string $shortname): void { unset($this->summarydata[$shortname]); } /** * Filter the data held, to keep only the information with the given shortnames. * * @param array $shortnames items to keep. */ public function filter_keeping_only(array $shortnames): void { foreach ($this->summarydata as $shortname => $rowdata) { if (!in_array($shortname, $shortnames)) { unset($this->summarydata[$shortname]); } } } /** * To aid conversion of old code. This converts the old array format into an instance of this class. * * @param array $items array of $shortname => [$title, $content]. * @return static */ public static function create_from_legacy_array(array $items): static { $summary = new static(); foreach ($items as $shortname => $item) { $summary->add_item($shortname, $item['title'], $item['content']); } return $summary; } /** * Initialise an instance of this class for a particular quiz attempt. * * @param quiz_attempt $attemptobj the attempt to summarise. * @param display_options $options options for what can be seen. * @param int|null $pageforlinkingtootherattempts if null, no links to other attempsts will be created. * If specified, the URL of this particular page of the attempt, otherwise * the URL will go to the first page. If -1, deduce $page from $slot. * @param bool|null $showall if true, the URL will be to review the entire attempt on one page, * and $page will be ignored. If null, a sensible default will be chosen. * @return self summary information. */ public static function create_for_attempt( quiz_attempt $attemptobj, display_options $options, ?int $pageforlinkingtootherattempts = null, ?bool $showall = null, ): static { global $DB, $USER; $summary = new static(); // Prepare summary information about the whole attempt. if (!$attemptobj->get_quiz()->showuserpicture && $attemptobj->get_userid() != $USER->id) { // If showuserpicture is true, the picture is shown elsewhere, so don't repeat it. $student = $DB->get_record('user', ['id' => $attemptobj->get_userid()]); $userpicture = new user_picture($student); $userpicture->courseid = $attemptobj->get_courseid(); $summary->add_item('user', $userpicture, new action_link( new moodle_url('/user/view.php', ['id' => $student->id, 'course' => $attemptobj->get_courseid()]), fullname($student, true), ) ); } if ($pageforlinkingtootherattempts !== null && $attemptobj->has_capability('mod/quiz:viewreports')) { $attemptlist = $attemptobj->links_to_other_attempts( $attemptobj->review_url(null, $pageforlinkingtootherattempts, $showall)); if ($attemptlist) { $summary->add_item('attemptlist', get_string('attempts', 'quiz'), $attemptlist); } } // Caption. $summary->set_caption(get_string('summaryofattemptscaption', 'quiz', $attemptobj->get_attempt_number())); // Attempt state. $summary->add_item('state', get_string('attemptstate', 'quiz'), quiz_attempt::state_name($attemptobj->get_attempt()->state)); // Timing information. $attempt = $attemptobj->get_attempt(); $quiz = $attemptobj->get_quiz(); $overtime = 0; if ($attempt->state == quiz_attempt::FINISHED) { if ($timetaken = ($attempt->timefinish - $attempt->timestart)) { if ($quiz->timelimit && $timetaken > ($quiz->timelimit + 60)) { $overtime = $timetaken - $quiz->timelimit; $overtime = format_time($overtime); } $timetaken = format_time($timetaken); } else { $timetaken = "-"; } } else { $timetaken = get_string('unfinished', 'quiz'); } $summary->add_item('startedon', get_string('startedon', 'quiz'), userdate($attempt->timestart)); if ($attempt->state == quiz_attempt::FINISHED) { $summary->add_item('completedon', get_string('completedon', 'quiz'), userdate($attempt->timefinish)); $summary->add_item('timetaken', get_string('attemptduration', 'quiz'), $timetaken); } if (!empty($overtime)) { $summary->add_item('overdue', get_string('overdue', 'quiz'), $overtime); } // Show marks (if the user is allowed to see marks at the moment). $grade = $summary->add_attempt_grades_if_appropriate($attemptobj, $options); // Any additional summary data from the behaviour. foreach ($attemptobj->get_additional_summary_data($options) as $shortname => $data) { $summary->add_item($shortname, $data['title'], $data['content']); } // Feedback if there is any, and the user is allowed to see it now. $feedback = $attemptobj->get_overall_feedback($grade); if ($options->overallfeedback && $feedback) { $summary->add_item('feedback', get_string('feedback', 'quiz'), $feedback); } return $summary; } /** * Add the grade information to this summary information. * * This is a helper used by {@see create_for_attempt()}. * * @param quiz_attempt $attemptobj the attempt to summarise. * @param display_options $options options for what can be seen. * @return float|null the overall attempt grade, if it exists, else null. Raw value, not formatted. */ public function add_attempt_grades_if_appropriate( quiz_attempt $attemptobj, display_options $options, ): ?float { $quiz = $attemptobj->get_quiz(); $grade = quiz_rescale_grade($attemptobj->get_sum_marks(), $quiz, false); if ($options->marks < question_display_options::MARK_AND_MAX) { // User can't see grades. return $grade; } if (!quiz_has_grades($quiz) || $attemptobj->get_state() != quiz_attempt::FINISHED) { // No grades to show. return $grade; } if (is_null($grade)) { // Attempt needs ot be graded. $this->add_item('grade', get_string('gradenoun'), quiz_format_grade($quiz, $grade)); return $grade; } // Grades for extra grade items, if any. foreach ($attemptobj->get_grade_item_totals() as $gradeitemid => $gradeoutof) { $this->add_item('marks' . $gradeitemid, format_string($gradeoutof->name), new grade_out_of($quiz, $gradeoutof->grade, $gradeoutof->maxgrade, style: abs($gradeoutof->maxgrade - 100) < grade_calculator::ALMOST_ZERO ? grade_out_of::NORMAL : grade_out_of::WITH_PERCENT)); } // Show raw marks only if they are different from the grade. if ($quiz->grade != $quiz->sumgrades) { $this->add_item('marks', get_string('marks', 'quiz'), new grade_out_of($quiz, $attemptobj->get_sum_marks(), $quiz->sumgrades, style: grade_out_of::SHORT)); } // Now the scaled grade. $this->add_item('grade', get_string('gradenoun'), new grade_out_of($quiz, $grade, $quiz->grade, style: abs($quiz->grade - 100) < grade_calculator::ALMOST_ZERO ? grade_out_of::NORMAL : grade_out_of::WITH_PERCENT)); return $grade; } public function export_for_template(renderer_base $output): array { $templatecontext = [ 'hasitems' => !empty($this->summarydata), 'items' => [], 'caption' => $this->caption, ]; foreach ($this->summarydata as $item) { if ($item['title'] instanceof renderable) { $title = $output->render($item['title']); } else { $title = $item['title']; } if ($item['content'] instanceof renderable) { $content = $output->render($item['content']); } else { $content = $item['content']; } $templatecontext['items'][] = (object) ['title' => $title, 'content' => $content]; } return $templatecontext; } public function get_template_name(renderer_base $renderer): string { // Only reason we are forced to implement this is that we want the quiz renderer // passed to export_for_template, not a core_renderer. return 'mod_quiz/attempt_summary_information'; } } PK9B\v%classes/output/renderer.phpnu[. namespace mod_quiz\output; use cm_info; use coding_exception; use context; use context_module; use html_table; use html_table_cell; use html_writer; use mod_quiz\access_manager; use mod_quiz\form\preflight_check_form; use mod_quiz\output\grades\grade_out_of; use mod_quiz\question\display_options; use mod_quiz\quiz_attempt; use moodle_url; use plugin_renderer_base; use popup_action; use question_display_options; use mod_quiz\quiz_settings; use renderable; use single_button; use stdClass; /** * The main renderer for the quiz module. * * @package mod_quiz * @category output * @copyright 2011 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class renderer extends plugin_renderer_base { /** * Builds the review page * * @param quiz_attempt $attemptobj an instance of quiz_attempt. * @param array $slots of slots to be displayed. * @param int $page the current page number * @param bool $showall whether to show entire attempt on one page. * @param bool $lastpage if true the current page is the last page. * @param display_options $displayoptions instance of display_options. * @param attempt_summary_information|array $summarydata summary information about the attempt. * Passing an array is deprecated. * @return string HTML to display. */ public function review_page(quiz_attempt $attemptobj, $slots, $page, $showall, $lastpage, display_options $displayoptions, $summarydata) { if (is_array($summarydata)) { debugging('Since Moodle 4.4, $summarydata passed to review_page should be a attempt_summary_information.', DEBUG_DEVELOPER); $summarydata = $this->filter_review_summary_table($summarydata, $page); $summarydata = attempt_summary_information::create_from_legacy_array($summarydata); } $output = ''; $output .= $this->header(); $output .= $this->review_attempt_summary($summarydata, $page); $output .= $this->review_form($page, $showall, $displayoptions, $this->questions($attemptobj, true, $slots, $page, $showall, $displayoptions), $attemptobj); $output .= $this->review_next_navigation($attemptobj, $page, $lastpage, $showall); $output .= $this->footer(); return $output; } /** * Renders the review question pop-up. * * @param quiz_attempt $attemptobj an instance of quiz_attempt. * @param int $slot which question to display. * @param int $seq which step of the question attempt to show. null = latest. * @param display_options $displayoptions instance of display_options. * @param attempt_summary_information|array $summarydata summary information about the attempt. * Passing an array is deprecated. * @return string HTML to display. */ public function review_question_page(quiz_attempt $attemptobj, $slot, $seq, display_options $displayoptions, $summarydata) { if (is_array($summarydata)) { debugging('Since Moodle 4.4, $summarydata passed to review_question_page should be a attempt_summary_information.', DEBUG_DEVELOPER); $summarydata = attempt_summary_information::create_from_legacy_array($summarydata); } $output = ''; $output .= $this->header(); $output .= html_writer::div($this->render($summarydata), 'mb-3'); if (!is_null($seq)) { $output .= $attemptobj->render_question_at_step($slot, $seq, true, $this); } else { $output .= $attemptobj->render_question($slot, true, $this); } $output .= $this->close_window_button(); $output .= $this->footer(); return $output; } /** * Renders the review question pop-up. * * @param quiz_attempt $attemptobj an instance of quiz_attempt. * @param string $message Why the review is not allowed. * @return string html to output. */ public function review_question_not_allowed(quiz_attempt $attemptobj, $message) { $output = ''; $output .= $this->header(); $output .= $this->heading(format_string($attemptobj->get_quiz_name(), true, ["context" => $attemptobj->get_quizobj()->get_context()])); $output .= $this->notification($message); $output .= $this->close_window_button(); $output .= $this->footer(); return $output; } /** * A chance to filter the information before display. * * Moodle core uses this to display less infomrmation on pages after the first. * This is a separate method, becaus it is a useful hook where themes can overrid things. * * @param attempt_summary_information $summarydata the data that will be displayed. Modify as desired. * @param int $page contains the current page number */ public function filter_review_attempt_summary( attempt_summary_information $summarydata, int $page ): void { if ($page > 0) { $summarydata->filter_keeping_only(['user', 'attemptlist']); } } /** * Outputs the overall summary of the attempt at the top of the review page. * * @param attempt_summary_information $summarydata contains row data for table. * @param int $page contains the current page number * @return string HTML to display. */ public function review_attempt_summary( attempt_summary_information $summarydata, int $page ): string { $this->filter_review_attempt_summary($summarydata, $page); return html_writer::div($this->render($summarydata), 'mb-3'); } /** * Filters the summarydata array. * * @param array $summarydata contains row data for table * @param int $page the current page number * @return array updated version of the $summarydata array. * @deprecated since Moodle 4.4. Replaced by filter_review_attempt_summary. */ protected function filter_review_summary_table($summarydata, $page) { debugging('filter_review_summary_table() is deprecated. Replaced by filter_review_attempt_summary().', DEBUG_DEVELOPER); if ($page == 0) { return $summarydata; } // Only show some of summary table on subsequent pages. foreach ($summarydata as $key => $rowdata) { if (!in_array($key, ['user', 'attemptlist'])) { unset($summarydata[$key]); } } return $summarydata; } /** * Outputs the table containing data from summary data array * * @param array $summarydata contains row data for table * @param int $page contains the current page number * @return string HTML to display. * @deprecated since Moodle 4.4. Replaced by review_attempt_summary. */ public function review_summary_table($summarydata, $page) { debugging('review_summary_table() is deprecated. Please use review_attempt_summary() instead.', DEBUG_DEVELOPER); $summarydata = $this->filter_review_summary_table($summarydata, $page); $this->render(attempt_summary_information::create_from_legacy_array($summarydata)); } /** * Renders each question * * @param quiz_attempt $attemptobj instance of quiz_attempt * @param bool $reviewing * @param array $slots array of integers relating to questions * @param int $page current page number * @param bool $showall if true shows attempt on single page * @param display_options $displayoptions instance of display_options */ public function questions(quiz_attempt $attemptobj, $reviewing, $slots, $page, $showall, display_options $displayoptions) { $output = ''; foreach ($slots as $slot) { $output .= $attemptobj->render_question($slot, $reviewing, $this, $attemptobj->review_url($slot, $page, $showall)); } return $output; } /** * Renders the main bit of the review page. * * @param int $page current page number * @param bool $showall if true display attempt on one page * @param display_options $displayoptions instance of display_options * @param string $content the rendered display of each question * @param quiz_attempt $attemptobj instance of quiz_attempt * @return string HTML to display. */ public function review_form($page, $showall, $displayoptions, $content, $attemptobj) { if ($displayoptions->flags != question_display_options::EDITABLE) { return $content; } $this->page->requires->js_init_call('M.mod_quiz.init_review_form', null, false, quiz_get_js_module()); $output = ''; $output .= html_writer::start_tag('form', ['action' => $attemptobj->review_url(null, $page, $showall), 'method' => 'post', 'class' => 'questionflagsaveform']); $output .= html_writer::start_tag('div'); $output .= $content; $output .= html_writer::empty_tag('input', ['type' => 'hidden', 'name' => 'sesskey', 'value' => sesskey()]); $output .= html_writer::start_tag('div', ['class' => 'submitbtns']); $output .= html_writer::empty_tag('input', ['type' => 'submit', 'class' => 'questionflagsavebutton btn btn-secondary', 'name' => 'savingflags', 'value' => get_string('saveflags', 'question')]); $output .= html_writer::end_tag('div'); $output .= html_writer::end_tag('div'); $output .= html_writer::end_tag('form'); return $output; } /** * Returns either a link or button. * * @param quiz_attempt $attemptobj instance of quiz_attempt */ public function finish_review_link(quiz_attempt $attemptobj) { $url = $attemptobj->view_url(); if ($attemptobj->get_access_manager(time())->attempt_must_be_in_popup()) { $this->page->requires->js_init_call('M.mod_quiz.secure_window.init_close_button', [$url->out(false)], false, quiz_get_js_module()); return html_writer::empty_tag('input', ['type' => 'button', 'value' => get_string('finishreview', 'quiz'), 'id' => 'secureclosebutton', 'class' => 'mod_quiz-next-nav btn btn-primary']); } else { return html_writer::link($url, get_string('finishreview', 'quiz'), ['class' => 'mod_quiz-next-nav']); } } /** * Creates the navigation links/buttons at the bottom of the review attempt page. * * Note, the name of this function is no longer accurate, but when the design * changed, it was decided to keep the old name for backwards compatibility. * * @param quiz_attempt $attemptobj instance of quiz_attempt * @param int $page the current page * @param bool $lastpage if true current page is the last page * @param bool|null $showall if true, the URL will be to review the entire attempt on one page, * and $page will be ignored. If null, a sensible default will be chosen. * * @return string HTML fragment. */ public function review_next_navigation(quiz_attempt $attemptobj, $page, $lastpage, $showall = null) { $nav = ''; if ($page > 0) { $nav .= link_arrow_left(get_string('navigateprevious', 'quiz'), $attemptobj->review_url(null, $page - 1, $showall), false, 'mod_quiz-prev-nav'); } if ($lastpage) { $nav .= $this->finish_review_link($attemptobj); } else { $nav .= link_arrow_right(get_string('navigatenext', 'quiz'), $attemptobj->review_url(null, $page + 1, $showall), false, 'mod_quiz-next-nav'); } return html_writer::tag('div', $nav, ['class' => 'submitbtns']); } /** * Return the HTML of the quiz timer. * * @param quiz_attempt $attemptobj instance of quiz_attempt * @param int $timenow timestamp to use as 'now'. * @return string HTML content. */ public function countdown_timer(quiz_attempt $attemptobj, $timenow) { $timeleft = $attemptobj->get_time_left_display($timenow); if ($timeleft !== false) { $ispreview = $attemptobj->is_preview(); $timerstartvalue = $timeleft; if (!$ispreview) { // Make sure the timer starts just above zero. If $timeleft was <= 0, then // this will just have the effect of causing the quiz to be submitted immediately. $timerstartvalue = max($timerstartvalue, 1); } $this->initialise_timer($timerstartvalue, $ispreview); } return $this->output->render_from_template('mod_quiz/timer', (object) []); } /** * Create a preview link * * @param moodle_url $url URL to restart the attempt. */ public function restart_preview_button($url) { return $this->single_button($url, get_string('startnewpreview', 'quiz')); } /** * Outputs the navigation block panel * * @param navigation_panel_base $panel */ public function navigation_panel(navigation_panel_base $panel) { $output = ''; $userpicture = $panel->user_picture(); if ($userpicture) { $fullname = fullname($userpicture->user); if ($userpicture->size) { $fullname = html_writer::div($fullname); } $output .= html_writer::tag('div', $this->render($userpicture) . $fullname, ['id' => 'user-picture', 'class' => 'clearfix']); } $output .= $panel->render_before_button_bits($this); $bcc = $panel->get_button_container_class(); $output .= html_writer::start_tag('div', ['class' => "qn_buttons clearfix $bcc"]); foreach ($panel->get_question_buttons() as $button) { $output .= $this->render($button); } $output .= html_writer::end_tag('div'); $output .= html_writer::tag('div', $panel->render_end_bits($this), ['class' => 'othernav']); $this->page->requires->js_init_call('M.mod_quiz.nav.init', null, false, quiz_get_js_module()); return $output; } /** * Display a quiz navigation button. * * @param navigation_question_button $button * @return string HTML fragment. */ protected function render_navigation_question_button(navigation_question_button $button) { $classes = ['qnbutton', $button->stateclass, $button->navmethod, 'btn']; $extrainfo = []; if ($button->currentpage) { $classes[] = 'thispage'; $extrainfo[] = get_string('onthispage', 'quiz'); } // Flagged? if ($button->flagged) { $classes[] = 'flagged'; $flaglabel = get_string('flagged', 'question'); } else { $flaglabel = ''; } $extrainfo[] = html_writer::tag('span', $flaglabel, ['class' => 'flagstate']); if ($button->isrealquestion) { $qnostring = 'questionnonav'; } else { $qnostring = 'questionnonavinfo'; } $tooltip = get_string('questionx', 'question', s($button->number)) . ' - ' . $button->statestring; $a = new stdClass(); $a->number = s($button->number); $a->attributes = implode(' ', $extrainfo); $tagcontents = html_writer::tag('span', '', ['class' => 'thispageholder']) . html_writer::tag('span', '', ['class' => 'trafficlight']) . get_string($qnostring, 'quiz', $a); $tagattributes = ['class' => implode(' ', $classes), 'id' => $button->id, 'title' => $tooltip, 'data-quiz-page' => $button->page]; if ($button->url) { return html_writer::link($button->url, $tagcontents, $tagattributes); } else { return html_writer::tag('span', $tagcontents, $tagattributes); } } /** * Display a quiz navigation heading. * * @param navigation_section_heading $heading the heading. * @return string HTML fragment. */ protected function render_navigation_section_heading(navigation_section_heading $heading) { if (empty($heading->heading)) { $headingtext = get_string('sectionnoname', 'quiz'); $class = ' dimmed_text'; } else { $headingtext = $heading->heading; $class = ''; } return $this->heading($headingtext, 3, 'mod_quiz-section-heading' . $class); } /** * Renders a list of links the other attempts. * * @param links_to_other_attempts $links * @return string HTML fragment. */ protected function render_links_to_other_attempts( links_to_other_attempts $links) { $attemptlinks = []; foreach ($links->links as $attempt => $url) { if (!$url) { $attemptlinks[] = html_writer::tag('strong', $attempt); } else { if ($url instanceof renderable) { $attemptlinks[] = $this->render($url); } else { $attemptlinks[] = html_writer::link($url, $attempt); } } } return implode(', ', $attemptlinks); } /** * Render a {@see grade_out_of}. * * Most of the logic is in methods of the grade_out_of class. However, * having this renderer method allows themes to override the default rendering. * * @param grade_out_of $grade * @return string HTML to output. */ protected function render_grade_out_of(grade_out_of $grade): string { return get_string($grade->get_string_key(), 'quiz', $grade->style_formatted_values($grade->get_formatted_values())); } /** * Render the 'start attempt' page. * * The student gets here if their interaction with the preflight check * from fails in some way (e.g. they typed the wrong password). * * @param \mod_quiz\quiz_settings $quizobj * @param preflight_check_form $mform * @return string */ public function start_attempt_page(quiz_settings $quizobj, preflight_check_form $mform) { $output = ''; $output .= $this->header(); $output .= $this->during_attempt_tertiary_nav($quizobj->view_url()); $output .= $this->heading(format_string($quizobj->get_quiz_name(), true, ["context" => $quizobj->get_context()])); $output .= $this->quiz_intro($quizobj->get_quiz(), $quizobj->get_cm()); $output .= $mform->render(); $output .= $this->footer(); return $output; } /** * Attempt Page * * @param quiz_attempt $attemptobj Instance of quiz_attempt * @param int $page Current page number * @param access_manager $accessmanager Instance of access_manager * @param array $messages An array of messages * @param array $slots Contains an array of integers that relate to questions * @param int $id The ID of an attempt * @param int $nextpage The number of the next page * @return string HTML to output. */ public function attempt_page($attemptobj, $page, $accessmanager, $messages, $slots, $id, $nextpage) { $output = ''; $output .= $this->header(); $output .= $this->during_attempt_tertiary_nav($attemptobj->view_url()); $output .= $this->quiz_notices($messages); $output .= $this->countdown_timer($attemptobj, time()); $output .= $this->attempt_form($attemptobj, $page, $slots, $id, $nextpage); $output .= $this->footer(); return $output; } /** * Render the tertiary navigation for pages during the attempt. * * @param string|moodle_url $quizviewurl url of the view.php page for this quiz. * @return string HTML to output. */ public function during_attempt_tertiary_nav($quizviewurl): string { $output = ''; $output .= html_writer::start_div('tertiary-navigation'); $output .= html_writer::start_div('row'); $output .= html_writer::start_div('navitem'); $output .= html_writer::link($quizviewurl, get_string('back'), ['class' => 'btn btn-secondary']); $output .= html_writer::end_div(); $output .= html_writer::end_div(); $output .= html_writer::end_div(); return $output; } /** * Returns any notices. * * @param array $messages */ public function quiz_notices($messages) { if (!$messages) { return ''; } return $this->notification( html_writer::tag('p', get_string('accessnoticesheader', 'quiz')) . $this->access_messages($messages), 'warning', false ); } /** * Outputs the form for making an attempt * * @param quiz_attempt $attemptobj * @param int $page Current page number * @param array $slots Array of integers relating to questions * @param int $id ID of the attempt * @param int $nextpage Next page number */ public function attempt_form($attemptobj, $page, $slots, $id, $nextpage) { $output = ''; // Start the form. $output .= html_writer::start_tag('form', ['action' => new moodle_url($attemptobj->processattempt_url(), ['cmid' => $attemptobj->get_cmid()]), 'method' => 'post', 'enctype' => 'multipart/form-data', 'accept-charset' => 'utf-8', 'id' => 'responseform']); $output .= html_writer::start_tag('div'); // Print all the questions. foreach ($slots as $slot) { $output .= $attemptobj->render_question($slot, false, $this, $attemptobj->attempt_url($slot, $page)); } $navmethod = $attemptobj->get_quiz()->navmethod; $output .= $this->attempt_navigation_buttons($page, $attemptobj->is_last_page($page), $navmethod); // Some hidden fields to track what is going on. $output .= html_writer::empty_tag('input', ['type' => 'hidden', 'name' => 'attempt', 'value' => $attemptobj->get_attemptid()]); $output .= html_writer::empty_tag('input', ['type' => 'hidden', 'name' => 'thispage', 'value' => $page, 'id' => 'followingpage']); $output .= html_writer::empty_tag('input', ['type' => 'hidden', 'name' => 'nextpage', 'value' => $nextpage]); $output .= html_writer::empty_tag('input', ['type' => 'hidden', 'name' => 'timeup', 'value' => '0', 'id' => 'timeup']); $output .= html_writer::empty_tag('input', ['type' => 'hidden', 'name' => 'sesskey', 'value' => sesskey()]); $output .= html_writer::empty_tag('input', ['type' => 'hidden', 'name' => 'mdlscrollto', 'value' => '', 'id' => 'mdlscrollto']); // Add a hidden field with questionids. Do this at the end of the form, so // if you navigate before the form has finished loading, it does not wipe all // the student's answers. $output .= html_writer::empty_tag('input', ['type' => 'hidden', 'name' => 'slots', 'value' => implode(',', $attemptobj->get_active_slots($page))]); // Finish the form. $output .= html_writer::end_tag('div'); $output .= html_writer::end_tag('form'); $output .= $this->connection_warning(); return $output; } /** * Display the prev/next buttons that go at the bottom of each page of the attempt. * * @param int $page the page number. Starts at 0 for the first page. * @param bool $lastpage is this the last page in the quiz? * @param string $navmethod Optional quiz attribute, 'free' (default) or 'sequential' * @return string HTML fragment. */ protected function attempt_navigation_buttons($page, $lastpage, $navmethod = 'free') { $output = ''; $output .= html_writer::start_tag('div', ['class' => 'submitbtns']); if ($page > 0 && $navmethod == 'free') { $output .= html_writer::empty_tag('input', ['type' => 'submit', 'name' => 'previous', 'value' => get_string('navigateprevious', 'quiz'), 'class' => 'mod_quiz-prev-nav btn btn-secondary', 'id' => 'mod_quiz-prev-nav']); $this->page->requires->js_call_amd('core_form/submit', 'init', ['mod_quiz-prev-nav']); } if ($lastpage) { $nextlabel = get_string('endtest', 'quiz'); } else { $nextlabel = get_string('navigatenext', 'quiz'); } $output .= html_writer::empty_tag('input', ['type' => 'submit', 'name' => 'next', 'value' => $nextlabel, 'class' => 'mod_quiz-next-nav btn btn-primary', 'id' => 'mod_quiz-next-nav']); $output .= html_writer::end_tag('div'); $this->page->requires->js_call_amd('core_form/submit', 'init', ['mod_quiz-next-nav']); return $output; } /** * Render a button which allows students to redo a question in the attempt. * * @param int $slot the number of the slot to generate the button for. * @param bool $disabled if true, output the button disabled. * @return string HTML fragment. */ public function redo_question_button($slot, $disabled) { $attributes = ['type' => 'submit', 'name' => 'redoslot' . $slot, 'value' => get_string('redoquestion', 'quiz'), 'class' => 'mod_quiz-redo_question_button btn btn-secondary', 'id' => 'redoslot' . $slot . '-submit', 'data-savescrollposition' => 'true', ]; if ($disabled) { $attributes['disabled'] = 'disabled'; } else { $this->page->requires->js_call_amd('core_question/question_engine', 'initSubmitButton', [$attributes['id']]); } return html_writer::div(html_writer::empty_tag('input', $attributes)); } /** * Initialise the JavaScript required to initialise the countdown timer. * * @param int $timerstartvalue time remaining, in seconds. * @param bool $ispreview true if this is a preview attempt. */ public function initialise_timer($timerstartvalue, $ispreview) { $options = [$timerstartvalue, (bool) $ispreview]; $this->page->requires->js_init_call('M.mod_quiz.timer.init', $options, false, quiz_get_js_module()); } /** * Output a page with an optional message, and JavaScript code to close the * current window and redirect the parent window to a new URL. * * @param moodle_url $url the URL to redirect the parent window to. * @param string $message message to display before closing the window. (optional) * @return string HTML to output. */ public function close_attempt_popup($url, $message = '') { $output = ''; $output .= $this->header(); $output .= $this->box_start(); if ($message) { $output .= html_writer::tag('p', $message); $output .= html_writer::tag('p', get_string('windowclosing', 'quiz')); $delay = 5; } else { $output .= html_writer::tag('p', get_string('pleaseclose', 'quiz')); $delay = 0; } $this->page->requires->js_init_call('M.mod_quiz.secure_window.close', [$url->out(false), $delay], false, quiz_get_js_module()); $output .= $this->box_end(); $output .= $this->footer(); return $output; } /** * Print each message in an array, surrounded by <p>, </p> tags. * * @param array $messages the array of message strings. * @return string HTML to output. */ public function access_messages($messages) { $output = ''; foreach ($messages as $message) { $output .= html_writer::tag('p', $message, ['class' => 'text-start']); } return $output; } /* * Summary Page */ /** * Create the summary page * * @param quiz_attempt $attemptobj * @param display_options $displayoptions */ public function summary_page($attemptobj, $displayoptions) { $output = ''; $output .= $this->header(); $output .= $this->during_attempt_tertiary_nav($attemptobj->view_url()); $output .= $this->heading(format_string($attemptobj->get_quiz_name())); $output .= $this->heading(get_string('summaryofattempt', 'quiz'), 3); $output .= $this->summary_table($attemptobj, $displayoptions); $output .= $this->summary_page_controls($attemptobj); $output .= $this->footer(); return $output; } /** * Generates the table of summarydata * * @param quiz_attempt $attemptobj * @param display_options $displayoptions */ public function summary_table($attemptobj, $displayoptions) { // Prepare the summary table header. $table = new html_table(); $table->attributes['class'] = 'generaltable quizsummaryofattempt boxaligncenter table table-striped table-hover'; $table->head = [get_string('question', 'quiz'), get_string('status', 'quiz')]; $table->align = ['left', 'left']; $table->size = ['', '']; $markscolumn = $displayoptions->marks >= question_display_options::MARK_AND_MAX; if ($markscolumn) { $table->head[] = get_string('marks', 'quiz'); $table->align[] = 'left'; $table->size[] = ''; } $tablewidth = count($table->align); $table->data = []; // Get the summary info for each question. $slots = $attemptobj->get_slots(); foreach ($slots as $slot) { // Add a section headings if we need one here. $heading = $attemptobj->get_heading_before_slot($slot); if ($heading !== null) { // There is a heading here. $rowclasses = 'quizsummaryheading'; if ($heading) { $heading = format_string($heading); } else { if (count($attemptobj->get_quizobj()->get_sections()) > 1) { // If this is the start of an unnamed section, and the quiz has more // than one section, then add a default heading. $heading = get_string('sectionnoname', 'quiz'); $rowclasses .= ' dimmed_text'; } } $cell = new html_table_cell(format_string($heading)); $cell->header = true; $cell->colspan = $tablewidth; $table->data[] = [$cell]; $table->rowclasses[] = $rowclasses; } // Don't display information items. if (!$attemptobj->is_real_question($slot)) { continue; } // Real question, show it. $flag = ''; if ($attemptobj->is_question_flagged($slot)) { // Quiz has custom JS manipulating these image tags - so we can't use the pix_icon method here. $flag = html_writer::empty_tag('img', ['src' => $this->image_url('i/flagged'), 'alt' => get_string('flagged', 'question'), 'class' => 'questionflag icon ms-2']); } if ($attemptobj->can_navigate_to($slot)) { $row = [html_writer::link($attemptobj->attempt_url($slot), $attemptobj->get_question_number($slot) . $flag), $attemptobj->get_question_status($slot, $displayoptions->correctness)]; } else { $row = [$attemptobj->get_question_number($slot) . $flag, $attemptobj->get_question_status($slot, $displayoptions->correctness)]; } if ($markscolumn) { $row[] = $attemptobj->get_question_mark($slot); } $table->data[] = $row; $table->rowclasses[] = 'quizsummary' . $slot . ' ' . $attemptobj->get_question_state_class( $slot, $displayoptions->correctness); } // Print the summary table. return html_writer::table($table); } /** * Creates any controls the page should have. * * @param quiz_attempt $attemptobj */ public function summary_page_controls($attemptobj) { $output = ''; // Return to place button. if ($attemptobj->get_state() == quiz_attempt::IN_PROGRESS) { $button = new single_button( new moodle_url($attemptobj->attempt_url(null, $attemptobj->get_currentpage())), get_string('returnattempt', 'quiz')); $output .= $this->container($this->container($this->render($button), 'controls'), 'submitbtns mdl-align'); } // Finish attempt button. $options = [ 'attempt' => $attemptobj->get_attemptid(), 'finishattempt' => 1, 'timeup' => 0, 'slots' => '', 'cmid' => $attemptobj->get_cmid(), 'sesskey' => sesskey(), ]; $button = new single_button( new moodle_url($attemptobj->processattempt_url(), $options), get_string('submitallandfinish', 'quiz')); $button->class = 'btn-finishattempt'; $button->formid = 'frm-finishattempt'; if ($attemptobj->get_state() == quiz_attempt::IN_PROGRESS) { $totalunanswered = 0; if ($attemptobj->get_quiz()->navmethod == 'free') { // Only count the unanswered question if the navigation method is set to free. $totalunanswered = $attemptobj->get_number_of_unanswered_questions(); } $this->page->requires->js_call_amd('mod_quiz/submission_confirmation', 'init', [$totalunanswered]); } $button->type = \single_button::BUTTON_PRIMARY; $duedate = $attemptobj->get_due_date(); $message = ''; if ($attemptobj->get_state() == quiz_attempt::OVERDUE) { $message = get_string('overduemustbesubmittedby', 'quiz', userdate($duedate)); } else { if ($duedate) { $message = get_string('mustbesubmittedby', 'quiz', userdate($duedate)); } } $output .= $this->countdown_timer($attemptobj, time()); $output .= $this->container($message . $this->container( $this->render($button), 'controls'), 'submitbtns mdl-align'); return $output; } /* * View Page */ /** * Generates the view page * * @param stdClass $course the course settings row from the database. * @param stdClass $quiz the quiz settings row from the database. * @param stdClass $cm the course_module settings row from the database. * @param context_module $context the quiz context. * @param view_page $viewobj * @return string HTML to display */ public function view_page($course, $quiz, $cm, $context, $viewobj) { $output = ''; $output .= $this->view_page_tertiary_nav($viewobj); $output .= $this->view_information($quiz, $cm, $context, $viewobj->infomessages); $output .= $this->view_result_info($quiz, $context, $cm, $viewobj); $output .= $this->render($viewobj->attemptslist); $output .= $this->box($this->view_page_buttons($viewobj), 'quizattempt'); return $output; } /** * Render the tertiary navigation for the view page. * * @param view_page $viewobj the information required to display the view page. * @return string HTML to output. */ public function view_page_tertiary_nav(view_page $viewobj): string { $content = ''; if ($viewobj->buttontext) { $attemptbtn = $this->start_attempt_button($viewobj->buttontext, $viewobj->startattempturl, $viewobj->preflightcheckform, $viewobj->popuprequired, $viewobj->popupoptions); $content .= html_writer::div($attemptbtn, 'navitem'); } if ($viewobj->canedit && !$viewobj->quizhasquestions) { $addquestionbutton = html_writer::link($viewobj->editurl, get_string('addquestion', 'quiz'), ['class' => 'btn btn-secondary']); $content .= html_writer::div($addquestionbutton, 'navitem'); } if ($content) { return html_writer::div(html_writer::div($content, 'd-flex'), 'tertiary-navigation'); } else { return ''; } } /** * Work out, and render, whatever buttons, and surrounding info, should appear * at the end of the review page. * * @param view_page $viewobj the information required to display the view page. * @return string HTML to output. */ public function view_page_buttons(view_page $viewobj) { $output = ''; if (!$viewobj->quizhasquestions) { $output .= html_writer::div( $this->notification(get_string('noquestions', 'quiz'), 'warning', false), 'text-start mb-3'); } $output .= $this->access_messages($viewobj->preventmessages); if ($viewobj->showbacktocourse) { $output .= $this->single_button($viewobj->backtocourseurl, get_string('backtocourse', 'quiz'), 'get', ['class' => 'continuebutton']); } return $output; } /** * Generates the view attempt button * * @param string $buttontext the label to display on the button. * @param moodle_url $url The URL to POST to in order to start the attempt. * @param preflight_check_form|null $preflightcheckform deprecated. * @param bool $popuprequired whether the attempt needs to be opened in a pop-up. * @param array $popupoptions the options to use if we are opening a popup. * @return string HTML fragment. */ public function start_attempt_button($buttontext, moodle_url $url, ?preflight_check_form $preflightcheckform = null, $popuprequired = false, $popupoptions = null) { $button = new single_button($url, $buttontext, 'post', single_button::BUTTON_PRIMARY); $button->class .= ' quizstartbuttondiv'; if ($popuprequired) { $button->class .= ' quizsecuremoderequired'; } $popupjsoptions = null; if ($popuprequired && $popupoptions) { $action = new popup_action('click', $url, 'popup', $popupoptions); $popupjsoptions = $action->get_js_options(); } $this->page->requires->js_call_amd('mod_quiz/preflightcheck', 'init', ['.quizstartbuttondiv [type=submit]', get_string('startattempt', 'quiz'), '#mod_quiz_preflight_form', $popupjsoptions]); return $this->render($button) . ($preflightcheckform ? $preflightcheckform->render() : ''); } /** * Generate a message saying that this quiz has no questions, with a button to * go to the edit page, if the user has the right capability. * * @param bool $canedit can the current user edit the quiz? * @param moodle_url $editurl URL of the edit quiz page. * @return string HTML to output. * * @deprecated since Moodle 4.0 MDL-71915 - please do not use this function any more. */ public function no_questions_message($canedit, $editurl) { debugging('no_questions_message() is deprecated, please use generate_no_questions_message() instead.', DEBUG_DEVELOPER); $output = html_writer::start_tag('div', ['class' => 'card text-center mb-3']); $output .= html_writer::start_tag('div', ['class' => 'card-body']); $output .= $this->notification(get_string('noquestions', 'quiz'), 'warning', false); if ($canedit) { $output .= $this->single_button($editurl, get_string('editquiz', 'quiz'), 'get'); } $output .= html_writer::end_tag('div'); $output .= html_writer::end_tag('div'); return $output; } /** * Outputs an error message for any guests accessing the quiz * * @param stdClass $course the course settings row from the database. * @param stdClass $quiz the quiz settings row from the database. * @param stdClass $cm the course_module settings row from the database. * @param context_module $context the quiz context. * @param array $messages Array containing any messages * @param view_page $viewobj */ public function view_page_guest($course, $quiz, $cm, $context, $messages, $viewobj) { $output = ''; $output .= $this->view_page_tertiary_nav($viewobj); $output .= $this->view_information($quiz, $cm, $context, $messages); $guestno = html_writer::tag('p', get_string('guestsno', 'quiz')); $liketologin = html_writer::tag('p', get_string('liketologin')); $referer = get_local_referer(false); $output .= $this->confirm($guestno . "\n\n" . $liketologin . "\n", get_login_url(), $referer); return $output; } /** * Outputs and error message for anyone who is not enrolled on the course. * * @param stdClass $course the course settings row from the database. * @param stdClass $quiz the quiz settings row from the database. * @param stdClass $cm the course_module settings row from the database. * @param context_module $context the quiz context. * @param array $messages Array containing any messages * @param view_page $viewobj */ public function view_page_notenrolled($course, $quiz, $cm, $context, $messages, $viewobj) { global $CFG; $output = ''; $output .= $this->view_page_tertiary_nav($viewobj); $output .= $this->view_information($quiz, $cm, $context, $messages); $youneedtoenrol = html_writer::tag('p', get_string('youneedtoenrol', 'quiz')); $button = html_writer::tag('p', $this->continue_button($CFG->wwwroot . '/course/view.php?id=' . $course->id)); $output .= $this->box($youneedtoenrol . "\n\n" . $button . "\n", 'generalbox', 'notice'); return $output; } /** * Output the page information * * @param stdClass $quiz the quiz settings. * @param cm_info|stdClass $cm the course_module object. * @param context $context the quiz context. * @param array $messages any access messages that should be described. * @param bool $quizhasquestions does quiz has questions added. * @return string HTML to output. */ public function view_information($quiz, $cm, $context, $messages, bool $quizhasquestions = false) { $output = ''; // Output any access messages. if ($messages) { $output .= $this->box($this->access_messages($messages), 'quizinfo'); } // Show number of attempts summary to those who can view reports. if (has_capability('mod/quiz:viewreports', $context)) { if ($strattemptnum = $this->quiz_attempt_summary_link_to_reports($quiz, $cm, $context)) { $output .= html_writer::tag('div', $strattemptnum, ['class' => 'quizattemptcounts']); } } if (has_any_capability(['mod/quiz:manageoverrides', 'mod/quiz:viewoverrides'], $context)) { if ($overrideinfo = $this->quiz_override_summary_links($quiz, $cm)) { $output .= html_writer::tag('div', $overrideinfo, ['class' => 'quizattemptcounts']); } } return $output; } /** * Output the quiz intro. * * @param stdClass $quiz the quiz settings. * @param stdClass $cm the course_module object. * @return string HTML to output. */ public function quiz_intro($quiz, $cm) { if (html_is_blank($quiz->intro)) { return ''; } return $this->box(format_module_intro('quiz', $quiz, $cm->id), 'generalbox', 'intro'); } /** * Generates the table heading. */ public function view_table_heading() { return $this->heading(get_string('summaryofattempts', 'quiz'), 3); } /** * Generates the table of data * * @param stdClass $quiz the quiz settings. * @param context_module $context the quiz context. * @param view_page $viewobj * @deprecated Since 4.4 please use the {@see list_of_attempts} renderable instead. */ public function view_table($quiz, $context, $viewobj) { debugging('view_table has been deprecated since 4.4 please use the list_of_attempts renderable instead.'); if (!$viewobj->attempts) { return ''; } // Prepare table header. $table = new html_table(); $table->attributes['class'] = 'generaltable table-striped quizattemptsummary table-hover'; $table->caption = get_string('summaryofattempts', 'quiz'); $table->captionhide = true; $table->head = []; $table->align = []; $table->size = []; if ($viewobj->attemptcolumn) { $table->head[] = get_string('attemptnumber', 'quiz'); $table->align[] = 'center'; $table->size[] = ''; } $table->head[] = get_string('attemptstate', 'quiz'); $table->align[] = 'left'; $table->size[] = ''; if ($viewobj->markcolumn) { $table->head[] = get_string('marks', 'quiz') . ' / ' . quiz_format_grade($quiz, $quiz->sumgrades); $table->align[] = 'center'; $table->size[] = ''; } if ($viewobj->gradecolumn) { $table->head[] = get_string('gradenoun') . ' / ' . quiz_format_grade($quiz, $quiz->grade); $table->align[] = 'center'; $table->size[] = ''; } if ($viewobj->canreviewmine) { $table->head[] = get_string('review', 'quiz'); $table->align[] = 'center'; $table->size[] = ''; } if ($viewobj->feedbackcolumn) { $table->head[] = get_string('feedback', 'quiz'); $table->align[] = 'left'; $table->size[] = ''; } // One row for each attempt. foreach ($viewobj->attemptobjs as $attemptobj) { $attemptoptions = $attemptobj->get_display_options(true); $row = []; // Add the attempt number. if ($viewobj->attemptcolumn) { if ($attemptobj->is_preview()) { $row[] = get_string('preview', 'quiz'); } else { $row[] = $attemptobj->get_attempt_number(); } } $row[] = $this->attempt_state($attemptobj); if ($viewobj->markcolumn) { if ($attemptoptions->marks >= question_display_options::MARK_AND_MAX && $attemptobj->is_finished()) { $row[] = quiz_format_grade($quiz, $attemptobj->get_sum_marks()); } else { $row[] = ''; } } // Outside the if because we may be showing feedback but not grades. $attemptgrade = quiz_rescale_grade($attemptobj->get_sum_marks(), $quiz, false); if ($viewobj->gradecolumn) { if ($attemptoptions->marks >= question_display_options::MARK_AND_MAX && $attemptobj->is_finished()) { // Highlight the highest grade if appropriate. if ($viewobj->overallstats && !$attemptobj->is_preview() && $viewobj->numattempts > 1 && !is_null($viewobj->mygrade) && $attemptobj->get_state() == quiz_attempt::FINISHED && $attemptgrade == $viewobj->mygrade && $quiz->grademethod == QUIZ_GRADEHIGHEST) { $table->rowclasses[$attemptobj->get_attempt_number()] = 'bestrow'; } $row[] = quiz_format_grade($quiz, $attemptgrade); } else { $row[] = ''; } } if ($viewobj->canreviewmine) { $row[] = $viewobj->accessmanager->make_review_link($attemptobj->get_attempt(), $attemptoptions, $this); } if ($viewobj->feedbackcolumn && $attemptobj->is_finished()) { if ($attemptoptions->overallfeedback) { $row[] = quiz_feedback_for_grade($attemptgrade, $quiz, $context); } else { $row[] = ''; } } if ($attemptobj->is_preview()) { $table->data['preview'] = $row; } else { $table->data[$attemptobj->get_attempt_number()] = $row; } } // End of loop over attempts. $output = ''; $output .= $this->view_table_heading(); $output .= html_writer::table($table); return $output; } /** * Generate a brief textual description of the current state of an attempt. * * @param quiz_attempt $attemptobj the attempt * @return string the appropriate lang string to describe the state. */ public function attempt_state($attemptobj) { switch ($attemptobj->get_state()) { case quiz_attempt::IN_PROGRESS: return get_string('stateinprogress', 'quiz'); case quiz_attempt::OVERDUE: return get_string('stateoverdue', 'quiz') . html_writer::tag('span', get_string('stateoverduedetails', 'quiz', userdate($attemptobj->get_due_date())), ['class' => 'statedetails']); case quiz_attempt::FINISHED: return get_string('statefinished', 'quiz') . html_writer::tag('span', get_string('statefinisheddetails', 'quiz', userdate($attemptobj->get_submitted_date())), ['class' => 'statedetails']); case quiz_attempt::ABANDONED: return get_string('stateabandoned', 'quiz'); default: throw new coding_exception('Unexpected attempt state'); } } /** * Generates data pertaining to quiz results * * @param stdClass $quiz Array containing quiz data * @param context_module $context The quiz context. * @param stdClass|cm_info $cm The course module information. * @param view_page $viewobj * @return string HTML to display. */ public function view_result_info($quiz, $context, $cm, $viewobj) { $output = ''; if (!$viewobj->numattempts && !$viewobj->gradecolumn && is_null($viewobj->mygrade)) { return $output; } $resultinfo = ''; if ($viewobj->overallstats) { if ($viewobj->moreattempts) { $a = new stdClass(); $a->method = quiz_get_grading_option_name($quiz->grademethod); $a->mygrade = quiz_format_grade($quiz, $viewobj->mygrade); $a->quizgrade = quiz_format_grade($quiz, $quiz->grade); $resultinfo .= $this->heading(get_string('gradesofar', 'quiz', $a), 3); } else { $a = new stdClass(); $a->grade = quiz_format_grade($quiz, $viewobj->mygrade); $a->maxgrade = quiz_format_grade($quiz, $quiz->grade); $a = get_string('outofshort', 'quiz', $a); $resultinfo .= $this->heading(get_string('yourfinalgradeis', 'quiz', $a), 3); } } if ($viewobj->mygradeoverridden) { $resultinfo .= html_writer::tag('p', get_string('overriddennotice', 'grades'), ['class' => 'overriddennotice']) . "\n"; } if ($viewobj->gradebookfeedback) { $resultinfo .= $this->heading(get_string('comment', 'quiz'), 3); $resultinfo .= html_writer::div($viewobj->gradebookfeedback, 'quizteacherfeedback') . "\n"; } if ($viewobj->feedbackcolumn) { $resultinfo .= $this->heading(get_string('overallfeedback', 'quiz'), 3); $resultinfo .= html_writer::div( quiz_feedback_for_grade($viewobj->mygrade, $quiz, $context), 'quizgradefeedback') . "\n"; } if ($resultinfo) { $output .= $this->box($resultinfo, 'generalbox', 'feedback'); } return $output; } /** * Output either a link to the review page for an attempt, or a button to * open the review in a popup window. * * @param moodle_url $url of the target page. * @param bool $reviewinpopup whether a pop-up is required. * @param array $popupoptions options to pass to the popup_action constructor. * @return string HTML to output. */ public function review_link($url, $reviewinpopup, $popupoptions) { if ($reviewinpopup) { $button = new single_button($url, get_string('review', 'quiz')); $button->add_action(new popup_action('click', $url, 'quizpopup', $popupoptions)); return $this->render($button); } else { return html_writer::link($url, get_string('review', 'quiz'), ['title' => get_string('reviewthisattempt', 'quiz')]); } } /** * Displayed where there might normally be a review link, to explain why the * review is not available at this time. * * @param string $message optional message explaining why the review is not possible. * @return string HTML to output. */ public function no_review_message($message) { return html_writer::nonempty_tag('span', $message, ['class' => 'noreviewmessage']); } /** * Returns the same as {@see quiz_num_attempt_summary()} but wrapped in a link to the quiz reports. * * @param stdClass $quiz the quiz object. Only $quiz->id is used at the moment. * @param stdClass $cm the cm object. Only $cm->course, $cm->groupmode and $cm->groupingid * fields are used at the moment. * @param context $context the quiz context. * @param bool $returnzero if false (default), when no attempts have been made '' is returned * instead of 'Attempts: 0'. * @param int $currentgroup if there is a concept of current group where this method is being * called (e.g. a report) pass it in here. Default 0 which means no current group. * @return string HTML fragment for the link. */ public function quiz_attempt_summary_link_to_reports($quiz, $cm, $context, $returnzero = false, $currentgroup = 0) { global $CFG; $summary = quiz_num_attempt_summary($quiz, $cm, $returnzero, $currentgroup); if (!$summary) { return ''; } require_once($CFG->dirroot . '/mod/quiz/report/reportlib.php'); $url = new moodle_url('/mod/quiz/report.php', [ 'id' => $cm->id, 'mode' => quiz_report_default_report($context)]); return html_writer::link($url, $summary); } /** * Render a summary of the number of group and user overrides, with corresponding links. * * @param stdClass $quiz the quiz settings. * @param cm_info|stdClass $cm the cm object. * @param int $currentgroup currently selected group, if there is one. * @return string HTML fragment for the link. */ public function quiz_override_summary_links(stdClass $quiz, cm_info|stdClass $cm, $currentgroup = 0): string { $baseurl = new moodle_url('/mod/quiz/overrides.php', ['cmid' => $cm->id]); $counts = quiz_override_summary($quiz, $cm, $currentgroup); $links = []; if ($counts['group']) { $links[] = html_writer::link(new moodle_url($baseurl, ['mode' => 'group']), get_string('overridessummarygroup', 'quiz', $counts['group'])); } if ($counts['user']) { $links[] = html_writer::link(new moodle_url($baseurl, ['mode' => 'user']), get_string('overridessummaryuser', 'quiz', $counts['user'])); } if (!$links) { return ''; } $links = implode(', ', $links); switch ($counts['mode']) { case 'onegroup': return get_string('overridessummarythisgroup', 'quiz', $links); case 'somegroups': return get_string('overridessummaryyourgroups', 'quiz', $links); case 'allgroups': return get_string('overridessummary', 'quiz', $links); default: throw new coding_exception('Unexpected mode ' . $counts['mode']); } } /** * Outputs a chart. * * @param \core\chart_base $chart The chart. * @param string $title The title to display above the graph. * @param array $attrs extra container html attributes. * @return string HTML of the graph. */ public function chart(\core\chart_base $chart, $title, $attrs = []) { return $this->heading($title, 3) . html_writer::tag('div', $this->render($chart), array_merge(['class' => 'graph'], $attrs)); } /** * Output a graph, or a message saying that GD is required. * * @param moodle_url $url the URL of the graph. * @param string $title the title to display above the graph. * @return string HTML of the graph. */ public function graph(moodle_url $url, $title) { $graph = html_writer::empty_tag('img', ['src' => $url, 'alt' => $title]); return $this->heading($title, 3) . html_writer::tag('div', $graph, ['class' => 'graph']); } /** * Output the connection warning messages, which are initially hidden, and * only revealed by JavaScript if necessary. */ public function connection_warning() { $options = ['filter' => false, 'newlines' => false]; $warning = format_text(get_string('connectionerror', 'quiz'), FORMAT_MARKDOWN, $options); $ok = format_text(get_string('connectionok', 'quiz'), FORMAT_MARKDOWN, $options); return html_writer::tag('div', $warning, ['id' => 'connection-error', 'style' => 'display: none;', 'role' => 'alert']) . html_writer::tag('div', $ok, ['id' => 'connection-ok', 'style' => 'display: none;', 'role' => 'alert']); } /** * Deprecated version of render_links_to_other_attempts. * * @param links_to_other_attempts $links * @return string HTML fragment. * @deprecated since Moodle 4.2. Please use render_links_to_other_attempts instead. * @todo MDL-76612 Final deprecation in Moodle 4.6 */ protected function render_mod_quiz_links_to_other_attempts(links_to_other_attempts $links) { return $this->render_links_to_other_attempts($links); } /** * Deprecated version of render_navigation_question_button. * * @param navigation_question_button $button * @return string HTML fragment. * @deprecated since Moodle 4.2. Please use render_links_to_other_attempts instead. * @todo MDL-76612 Final deprecation in Moodle 4.6 */ protected function render_quiz_nav_question_button(navigation_question_button $button) { return $this->render_navigation_question_button($button); } /** * Deprecated version of render_navigation_section_heading. * * @param navigation_section_heading $heading the heading. * @return string HTML fragment. * @deprecated since Moodle 4.2. Please use render_links_to_other_attempts instead. * @todo MDL-76612 Final deprecation in Moodle 4.6 */ protected function render_quiz_nav_section_heading(navigation_section_heading $heading) { return $this->render_navigation_section_heading($heading); } } PK9B\}D&classes/output/grades/grade_out_of.phpnu[. namespace mod_quiz\output\grades; use html_writer; use renderable; use stdClass; /** * Represents a grade out of a give total, that wants to be output in a particular way. * * @package mod_quiz * @category output * @copyright 2024 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class grade_out_of implements renderable { /** @var string Indicates we want a short rendering. Also the lang string used. */ const SHORT = 'outofshort'; /** @var string Indicates we want the default rendering. Also the lang string used. */ const NORMAL = 'outof'; /** @var string like normal, but with the percent equivalent in brackets. Also the lang string used */ const WITH_PERCENT = 'outofpercent'; /** * Constructor. * * @param stdClass $quiz Quiz settings. * @param float $grade the mark to show. * @param float $maxgrade the total to show it out of. * @param string|null $name optional, a name for what this grade is. * @param string $style which format to use, grade_out_of::NORMAL (default), ::SHORT or ::WITH_PERCENT. */ public function __construct( /** @var stdClass Quiz settings (so we can access the settings like decimal places). */ public readonly stdClass $quiz, /** @var float the grade to show. */ public float $grade, /** @var float the total the grade is out of. */ public float $maxgrade, /** @var string|null optional, a name for what this grade is. Must be output via format_string. */ public readonly ?string $name = null, /** @var string The display style, one of the consts above. */ public readonly string $style = self::NORMAL, ) { } /** * Get the lang string to use to display the grade in the requested style. * * @return string lang string key from the mod_quiz lang pack. */ public function get_string_key(): string { return $this->style; } /** * Get the formatted values to be inserted into the {@see get_string_key()} string placeholders. * * Values are not styled. To apply the recommended styling, call {@see style_formatted_values()} * * @return stdClass to be passed as the third argument to get_string(). */ public function get_formatted_values(): stdClass { $a = new stdClass(); $a->grade = quiz_format_grade($this->quiz, $this->grade); $a->maxgrade = quiz_format_grade($this->quiz, $this->maxgrade); if ($this->style === self::WITH_PERCENT) { $a->percent = format_float($this->grade * 100 / $this->maxgrade, $this->quiz->decimalpoints, true, true); } return $a; } /** * Apply the normal styling to the values returned by {@see get_formatted_values()}. * * @param stdClass $a formatted values, as returned by get_formatted_values. * @return stdClass same structure, with some values wrapped in <b> tags. */ public function style_formatted_values(stdClass $a): stdClass { if ($this->style !== self::SHORT) { $a->grade = html_writer::tag('b', $a->grade); } if ($this->style === self::WITH_PERCENT) { $a->percent = html_writer::tag('b', $a->percent); } return $a; } } PK9B\L @*classes/output/navigation_panel_review.phpnu[. namespace mod_quiz\output; use html_writer; /** * Specialisation of {@see navigation_panel_base} for the review quiz page. * * This class is not currently renderable or templatable, but it probably should be in the future, * which is why it is already in the output namespace. * * @package mod_quiz * @category output * @copyright 2008 Tim Hunt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class navigation_panel_review extends navigation_panel_base { public function get_question_url($slot) { return $this->attemptobj->review_url($slot, -1, $this->showall, $this->page); } public function render_end_bits(renderer $output) { $html = ''; if ($this->attemptobj->get_num_pages() > 1) { if ($this->showall) { $html .= html_writer::link($this->attemptobj->review_url(null, 0, false), get_string('showeachpage', 'quiz')); } else { $html .= html_writer::link($this->attemptobj->review_url(null, 0, true), get_string('showall', 'quiz')); } } $html .= $output->finish_review_link($this->attemptobj); $html .= $this->render_restart_preview_link($output); return $html; } } PK9B\Iy#classes/output/question_chooser.phpnu[. /** * The question_chooser renderable. * * @package mod_quiz * @copyright 2016 Frédéric Massart - FMCorz.net * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace mod_quiz\output; /** * The question_chooser renderable class. * * @package mod_quiz * @copyright 2016 Frédéric Massart - FMCorz.net * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class question_chooser extends \qbank_editquestion\qbank_chooser { } PK9B\ classes/output/edit_renderer.phpnu[. /** * Renderer outputting the quiz editing UI. * * @package mod_quiz * @copyright 2013 The Open University. * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace mod_quiz\output; use core_question\local\bank\question_version_status; use \mod_quiz\structure; use \html_writer; use qbank_previewquestion\question_preview_options; use question_bank; use renderable; /** * Renderer outputting the quiz editing UI. * * @copyright 2013 The Open University. * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @since Moodle 2.7 */ class edit_renderer extends \plugin_renderer_base { /** @var string The toggle group name of the checkboxes for the toggle-all functionality. */ protected $togglegroup = 'quiz-questions'; /** * Render the edit page * * @param \mod_quiz\quiz_settings $quizobj object containing all the quiz settings information. * @param structure $structure object containing the structure of the quiz. * @param \core_question\local\bank\question_edit_contexts $contexts the relevant question bank contexts. * @param \moodle_url $pageurl the canonical URL of this page. * @param array $pagevars the variables from {@link question_edit_setup()}. * @return string HTML to output. */ public function edit_page( \mod_quiz\quiz_settings $quizobj, structure $structure, \core_question\local\bank\question_edit_contexts $contexts, \moodle_url $pageurl, array $pagevars, ) { $output = ''; // Page title. $output .= $this->heading(get_string('questions', 'quiz')); // Information at the top. $output .= $this->quiz_state_warnings($structure); $output .= html_writer::start_div('mod_quiz-edit-top-controls'); $output .= html_writer::start_div('d-flex justify-content-between flex-wrap mb-1'); $output .= html_writer::start_div('d-flex align-items-center justify-content-around'); $output .= $this->quiz_information($structure); $output .= html_writer::end_tag('div'); $output .= $this->maximum_grade_input($structure, $pageurl); $output .= html_writer::end_tag('div'); $output .= html_writer::start_div('d-flex justify-content-between flex-wrap mb-1'); $output .= html_writer::start_div('mod_quiz-edit-action-buttons btn-group edit-toolbar', ['role' => 'group']); $output .= $this->repaginate_button($structure, $pageurl); $output .= $this->selectmultiple_button($structure); $output .= html_writer::end_tag('div'); $output .= html_writer::start_div('d-flex flex-column justify-content-around'); $output .= $this->total_marks($quizobj->get_quiz()); $output .= html_writer::end_tag('div'); $output .= html_writer::end_tag('div'); $output .= $this->selectmultiple_controls($structure); $output .= html_writer::end_tag('div'); // Show the questions organised into sections and pages. $output .= $this->start_section_list($structure); foreach ($structure->get_sections() as $section) { $output .= $this->start_section($structure, $section); $output .= $this->questions_in_section($structure, $section, $contexts, $pagevars, $pageurl); if ($structure->is_last_section($section)) { $output .= \html_writer::start_div('last-add-menu'); $output .= html_writer::tag('span', $this->add_menu_actions($structure, 0, $pageurl, $contexts, $pagevars), ['class' => 'add-menu-outer']); $output .= \html_writer::end_div(); } $output .= $this->end_section(); } $output .= $this->end_section_list(); // Initialise the JavaScript. $this->initialise_editing_javascript($structure, $contexts, $pagevars, $pageurl); // Include the contents of any other popups required. if ($structure->can_be_edited()) { $thiscontext = $contexts->lowest(); $this->page->requires->js_call_amd('mod_quiz/modal_quiz_question_bank', 'init', [ $thiscontext->id, $quizobj->get_cm()->id, $quizobj->get_cm()->id, ]); $this->page->requires->js_call_amd('mod_quiz/modal_add_random_question', 'init', [ $thiscontext->id, $quizobj->get_cm()->id, $pagevars['cat'], $pageurl->out_as_local_url(true), $pageurl->param('cmid'), \core\plugininfo\qbank::is_plugin_enabled(\qbank_managecategories\helper::PLUGINNAME), ]); // Include the question chooser. $output .= $this->question_chooser(); } return $output; } /** * Render any warnings that might be required about the state of the quiz, * e.g. if it has been attempted, or if the shuffle questions option is * turned on. * * @param structure $structure the quiz structure. * @return string HTML to output. */ public function quiz_state_warnings(structure $structure) { $warnings = $structure->get_edit_page_warnings(); if (empty($warnings)) { return ''; } $output = []; foreach ($warnings as $warning) { $output[] = \html_writer::tag('p', $warning); } return $this->box(implode("\n", $output), 'statusdisplay'); } /** * Render the status bar. * * @param structure $structure the quiz structure. * @return string HTML to output. */ public function quiz_information(structure $structure) { list($currentstatus, $explanation) = $structure->get_dates_summary(); $output = html_writer::span( get_string('numquestionsx', 'quiz', $structure->get_question_count()), 'numberofquestions') . ' | ' . html_writer::span($currentstatus, 'quizopeningstatus', ['title' => $explanation]); return html_writer::div($output, 'statusbar'); } /** * Render the form for setting a quiz' overall grade * * @param structure $structure the quiz structure. * @param \moodle_url $pageurl the canonical URL of this page. * @return string HTML to output. */ public function maximum_grade_input($structure, \moodle_url $pageurl) { $output = ''; $output .= html_writer::start_div('maxgrade', ['class' => 'mt-2 mt-sm-0']); $output .= html_writer::start_tag('form', ['method' => 'post', 'action' => 'edit.php', 'class' => 'quizsavegradesform']); $output .= html_writer::start_tag('fieldset', ['class' => 'invisiblefieldset d-flex align-items-center']); $output .= html_writer::empty_tag('input', ['type' => 'hidden', 'name' => 'sesskey', 'value' => sesskey()]); $output .= html_writer::input_hidden_params($pageurl); $output .= html_writer::tag('label', get_string('maximumgrade') . ' ', ['for' => 'inputmaxgrade', 'class' => 'd-inline-block w-auto mb-0']); $output .= html_writer::empty_tag('input', ['type' => 'text', 'id' => 'inputmaxgrade', 'name' => 'maxgrade', 'size' => ($structure->get_decimal_places_for_grades() + 2), 'value' => $structure->formatted_quiz_grade(), 'class' => 'form-control d-inline-block align-middle w-auto ms-1']); $output .= html_writer::empty_tag('input', ['type' => 'submit', 'class' => 'btn btn-secondary ms-1 d-inline-block w-auto ', 'name' => 'savechanges', 'value' => get_string('save', 'quiz')]); $output .= html_writer::end_tag('fieldset'); $output .= html_writer::end_tag('form'); $output .= html_writer::end_tag('div'); return $output; } /** * Return the repaginate button * @param structure $structure the structure of the quiz being edited. * @param \moodle_url $pageurl the canonical URL of this page. * @return string HTML to output. */ protected function repaginate_button(structure $structure, \moodle_url $pageurl) { $header = html_writer::tag('span', get_string('repaginatecommand', 'quiz'), ['class' => 'repaginatecommand']); $form = $this->repaginate_form($structure, $pageurl); $buttonoptions = [ 'type' => 'submit', 'name' => 'repaginate', 'id' => 'repaginatecommand', 'value' => get_string('repaginatecommand', 'quiz'), 'class' => 'btn btn-secondary me-1', 'data-header' => $header, 'data-form' => $form, ]; if (!$structure->can_be_repaginated()) { $buttonoptions['disabled'] = 'disabled'; } else { $this->page->requires->js_call_amd('mod_quiz/repaginate', 'init'); } return html_writer::empty_tag('input', $buttonoptions); } /** * Generate the bulk action button. * * @param structure $structure the structure of the quiz being edited. * @return string HTML to output. */ protected function selectmultiple_button(structure $structure) { $buttonoptions = [ 'type' => 'button', 'name' => 'selectmultiple', 'id' => 'selectmultiplecommand', 'value' => get_string('selectmultipleitems', 'quiz'), 'class' => 'btn btn-secondary' ]; if (!$structure->can_be_edited()) { $buttonoptions['disabled'] = 'disabled'; } return html_writer::tag('button', get_string('selectmultipleitems', 'quiz'), $buttonoptions); } /** * Generate the controls that appear when the bulk action button is pressed. * * @param structure $structure the structure of the quiz being edited. * @return string HTML to output. */ protected function selectmultiple_controls(structure $structure) { $output = ''; // Bulk action button delete and bulk action button cancel. $buttondeleteoptions = [ 'type' => 'button', 'id' => 'selectmultipledeletecommand', 'value' => get_string('deleteselected', 'mod_quiz'), 'class' => 'btn btn-secondary', 'data-action' => 'toggle', 'data-togglegroup' => $this->togglegroup, 'data-toggle' => 'action', 'disabled' => true ]; $buttoncanceloptions = [ 'type' => 'button', 'id' => 'selectmultiplecancelcommand', 'value' => get_string('cancel', 'moodle'), 'class' => 'btn btn-secondary' ]; $groupoptions = [ 'class' => 'btn-group selectmultiplecommand actions m-1', 'role' => 'group' ]; $output .= html_writer::tag('div', html_writer::tag('button', get_string('deleteselected', 'mod_quiz'), $buttondeleteoptions) . " " . html_writer::tag('button', get_string('cancel', 'moodle'), $buttoncanceloptions), $groupoptions); $toolbaroptions = [ 'class' => 'btn-toolbar m-1', 'role' => 'toolbar', 'aria-label' => get_string('selectmultipletoolbar', 'quiz'), ]; // Select all/deselect all questions. $selectallid = 'questionselectall'; $selectalltext = get_string('selectall', 'moodle'); $deselectalltext = get_string('deselectall', 'moodle'); $mastercheckbox = new \core\output\checkbox_toggleall($this->togglegroup, true, [ 'id' => $selectallid, 'name' => $selectallid, 'value' => 1, 'label' => $selectalltext, 'selectall' => $selectalltext, 'deselectall' => $deselectalltext, ], true); $selectdeselect = html_writer::div($this->render($mastercheckbox), 'selectmultiplecommandbuttons'); $output .= html_writer::tag('div', $selectdeselect, $toolbaroptions); return $output; } /** * Return the repaginate form * @param structure $structure the structure of the quiz being edited. * @param \moodle_url $pageurl the canonical URL of this page. * @return string HTML to output. */ protected function repaginate_form(structure $structure, \moodle_url $pageurl) { $perpage = []; $perpage[0] = get_string('allinone', 'quiz'); for ($i = 1; $i <= 50; ++$i) { $perpage[$i] = $i; } $hiddenurl = clone($pageurl); $hiddenurl->param('sesskey', sesskey()); $select = html_writer::select($perpage, 'questionsperpage', $structure->get_questions_per_page(), false, ['class' => 'form-select']); $buttonattributes = [ 'type' => 'submit', 'name' => 'repaginate', 'value' => get_string('go'), 'class' => 'btn btn-secondary ms-1' ]; $formcontent = html_writer::tag('form', html_writer::div( html_writer::input_hidden_params($hiddenurl) . get_string('repaginate', 'quiz', $select) . html_writer::empty_tag('input', $buttonattributes) ), ['action' => 'edit.php', 'method' => 'post']); return html_writer::div($formcontent, '', ['id' => 'repaginatedialog']); } /** * Render the total marks available for the quiz. * * @param \stdClass $quiz the quiz settings from the database. * @return string HTML to output. */ public function total_marks($quiz) { $totalmark = html_writer::span(quiz_format_grade($quiz, $quiz->sumgrades), 'mod_quiz_summarks'); return html_writer::tag('span', get_string('totalmarksx', 'quiz', $totalmark), ['class' => 'totalpoints']); } /** * Generate the starting container html for the start of a list of sections * @param structure $structure the structure of the quiz being edited. * @return string HTML to output. */ protected function start_section_list(structure $structure) { $class = 'slots'; if ($structure->get_section_count() == 1) { $class .= ' only-one-section'; } return html_writer::start_tag('ul', ['class' => $class, 'role' => 'presentation']); } /** * Generate the closing container html for the end of a list of sections * @return string HTML to output. */ protected function end_section_list() { return html_writer::end_tag('ul'); } /** * Display the start of a section, before the questions. * * @param structure $structure the structure of the quiz being edited. * @param \stdClass $section The quiz_section entry from DB * @return string HTML to output. */ protected function start_section($structure, $section) { $output = ''; $sectionstyle = ''; if ($structure->is_only_one_slot_in_section($section)) { $sectionstyle .= ' only-has-one-slot'; } if ($section->shufflequestions) { $sectionstyle .= ' shuffled'; } if ($section->heading) { $sectionheadingtext = format_string($section->heading); $sectionheading = html_writer::span($sectionheadingtext, 'instancesection'); } else { // Use a visually-hidden default section heading, so we don't end up with an empty section heading. $sectionheadingtext = get_string('sectionnoname', 'quiz'); $sectionheading = html_writer::span($sectionheadingtext, 'instancesection visually-hidden'); } $output .= html_writer::start_tag('li', ['id' => 'section-'.$section->id, 'class' => 'section main clearfix'.$sectionstyle, 'role' => 'presentation', 'data-sectionname' => $sectionheadingtext]); $output .= html_writer::start_div('content'); $output .= html_writer::start_div('section-heading'); $headingtext = $this->heading(html_writer::span($sectionheading, 'sectioninstance'), 3); if (!$structure->can_be_edited()) { $editsectionheadingicon = ''; } else { $editsectionheadingicon = html_writer::link(new \moodle_url('#'), $this->pix_icon('t/editstring', get_string('sectionheadingedit', 'quiz', $sectionheadingtext), 'moodle', ['class' => 'editicon visibleifjs']), ['class' => 'editing_section', 'data-action' => 'edit_section_title', 'role' => 'button']); } $output .= html_writer::div($headingtext . $editsectionheadingicon, 'instancesectioncontainer'); if (!$structure->is_first_section($section) && $structure->can_be_edited()) { $output .= $this->section_remove_icon($section); } $output .= $this->section_shuffle_questions($structure, $section); $output .= html_writer::end_div($output, 'section-heading'); return $output; } /** * Display a checkbox for shuffling question within a section. * * @param structure $structure object containing the structure of the quiz. * @param \stdClass $section data from the quiz_section table. * @return string HTML to output. */ public function section_shuffle_questions(structure $structure, $section) { $checkboxattributes = [ 'type' => 'checkbox', 'id' => 'shuffle-' . $section->id, 'value' => 1, 'data-action' => 'shuffle_questions', 'class' => 'cm-edit-action', ]; if (!$structure->can_be_edited()) { $checkboxattributes['disabled'] = 'disabled'; } if ($section->shufflequestions) { $checkboxattributes['checked'] = 'checked'; } if ($structure->is_first_section($section)) { $help = $this->help_icon('shufflequestions', 'quiz'); } else { $help = ''; } $helpspan = html_writer::span($help, 'shuffle-help-tip'); $progressspan = html_writer::span('', 'shuffle-progress'); $checkbox = html_writer::empty_tag('input', $checkboxattributes); $label = html_writer::label(get_string('shufflequestions', 'quiz'), $checkboxattributes['id'], false); return html_writer::span($progressspan . $checkbox . $label. ' ' . $helpspan, 'instanceshufflequestions', ['data-action' => 'shuffle_questions']); } /** * Display the end of a section, after the questions. * * @return string HTML to output. */ protected function end_section() { $output = html_writer::end_tag('div'); $output .= html_writer::end_tag('li'); return $output; } /** * Render an icon to remove a section from the quiz. * * @param stdClass $section the section to be removed. * @return string HTML to output. */ public function section_remove_icon($section) { $title = get_string('sectionheadingremove', 'quiz', format_string($section->heading)); $url = new \moodle_url('/mod/quiz/edit.php', ['sesskey' => sesskey(), 'removesection' => '1', 'sectionid' => $section->id]); $image = $this->pix_icon('t/delete', $title); return $this->action_link($url, $image, null, [ 'class' => 'cm-edit-action editing_delete', 'data-action' => 'deletesection']); } /** * Renders HTML to display the questions in a section of the quiz. * * This function calls {@link core_course_renderer::quiz_section_question()} * * @param structure $structure object containing the structure of the quiz. * @param \stdClass $section information about the section. * @param \core_question\local\bank\question_edit_contexts $contexts the relevant question bank contexts. * @param array $pagevars the variables from {@link \question_edit_setup()}. * @param \moodle_url $pageurl the canonical URL of this page. * @return string HTML to output. */ public function questions_in_section(structure $structure, $section, $contexts, $pagevars, $pageurl) { $output = ''; foreach ($structure->get_slots_in_section($section->id) as $slot) { $output .= $this->question_row($structure, $slot, $contexts, $pagevars, $pageurl); } $this->page->requires->js_call_amd('mod_quiz/question_slot', 'init'); return html_writer::tag('ul', $output, ['class' => 'section img-text']); } /** * Displays one question with the surrounding controls. * * @param structure $structure object containing the structure of the quiz. * @param int $slot which slot we are outputting. * @param \core_question\local\bank\question_edit_contexts $contexts the relevant question bank contexts. * @param array $pagevars the variables from {@link \question_edit_setup()}. * @param \moodle_url $pageurl the canonical URL of this page. * @return string HTML to output. */ public function question_row(structure $structure, $slot, $contexts, $pagevars, $pageurl) { $output = ''; $output .= $this->page_row($structure, $slot, $contexts, $pagevars, $pageurl); // Page split/join icon. $joinhtml = ''; if ($structure->can_be_edited() && !$structure->is_last_slot_in_quiz($slot) && !$structure->is_last_slot_in_section($slot)) { $joinhtml = $this->page_split_join_button($structure, $slot); } // Question HTML. $questionhtml = $this->question($structure, $slot, $pageurl); $qtype = $structure->get_question_type_for_slot($slot); $questionclasses = 'activity ' . $qtype . ' qtype_' . $qtype . ' slot'; $output .= html_writer::tag('li', $questionhtml . $joinhtml, ['class' => $questionclasses, 'id' => 'slot-' . $structure->get_slot_id_for_slot($slot), 'data-canfinish' => $structure->can_finish_during_the_attempt($slot)]); return $output; } /** * Displays one question with the surrounding controls. * * @param structure $structure object containing the structure of the quiz. * @param int $slot the first slot on the page we are outputting. * @param \core_question\local\bank\question_edit_contexts $contexts the relevant question bank contexts. * @param array $pagevars the variables from {@link \question_edit_setup()}. * @param \moodle_url $pageurl the canonical URL of this page. * @return string HTML to output. */ public function page_row(structure $structure, $slot, $contexts, $pagevars, $pageurl) { $output = ''; $pagenumber = $structure->get_page_number_for_slot($slot); // Put page in a heading for accessibility and styling. $page = $this->heading(get_string('page') . ' ' . $pagenumber, 4); if ($structure->is_first_slot_on_page($slot)) { // Add the add-menu at the page level. $addmenu = html_writer::tag('span', $this->add_menu_actions($structure, $pagenumber, $pageurl, $contexts, $pagevars), ['class' => 'add-menu-outer']); $addquestionform = $this->add_question_form($structure, $pagenumber, $pageurl, $pagevars); $output .= html_writer::tag('li', $page . $addmenu . $addquestionform, ['class' => 'pagenumber activity yui3-dd-drop page', 'id' => 'page-' . $pagenumber]); } return $output; } /** * Returns the add menu that is output once per page. * @param structure $structure object containing the structure of the quiz. * @param int $page the page number that this menu will add to. * @param \moodle_url $pageurl the canonical URL of this page. * @param \core_question\local\bank\question_edit_contexts $contexts the relevant question bank contexts. * @param array $pagevars the variables from {@link \question_edit_setup()}. * @return string HTML to output. */ public function add_menu_actions(structure $structure, $page, \moodle_url $pageurl, \core_question\local\bank\question_edit_contexts $contexts, array $pagevars) { $actions = $this->edit_menu_actions($structure, $page, $pageurl, $pagevars); if (empty($actions)) { return ''; } $menu = new \action_menu(); $trigger = html_writer::tag('span', get_string('add', 'quiz'), ['class' => 'add-menu']); $menu->set_menu_trigger($trigger); // The menu appears within an absolutely positioned element causing width problems. // Make sure no-wrap is set so that we don't get a squashed menu. $menu->set_nowrap_on_items(true); // Disable the link if quiz has attempts. if (!$structure->can_be_edited()) { return $this->render($menu); } foreach ($actions as $action) { if ($action instanceof \action_menu_link) { $action->add_class('add-menu'); } $menu->add($action); } $menu->attributes['class'] .= ' page-add-actions commands'; // Prioritise the menu ahead of all other actions. $menu->prioritise = true; return $this->render($menu); } /** * Returns the list of actions to go in the add menu. * @param structure $structure object containing the structure of the quiz. * @param int $page the page number that this menu will add to. * @param \moodle_url $pageurl the canonical URL of this page. * @param array $pagevars the variables from {@link \question_edit_setup()}. * @return array the actions. */ public function edit_menu_actions(structure $structure, $page, \moodle_url $pageurl, array $pagevars) { $questioncategoryid = question_get_category_id_from_pagevars($pagevars); static $str; if (!isset($str)) { $str = get_strings(['addasection', 'addaquestion', 'addarandomquestion', 'addarandomselectedquestion', 'questionbank'], 'quiz'); } // Get section, page, slotnumber and maxmark. $actions = []; // Add a new question to the quiz. $returnurl = new \moodle_url($pageurl, ['addonpage' => $page]); $params = ['returnurl' => $returnurl->out_as_local_url(false), 'cmid' => $structure->get_cmid(), 'category' => $questioncategoryid, 'addonpage' => $page, 'appendqnumstring' => 'addquestion']; $actions['addaquestion'] = new \action_menu_link_secondary( new \moodle_url('/question/bank/editquestion/addquestion.php', $params), new \pix_icon('t/add', $str->addaquestion, 'moodle', ['class' => 'iconsmall', 'title' => '']), $str->addaquestion, ['class' => 'cm-edit-action addquestion', 'data-action' => 'addquestion'] ); // Call question bank. $icon = new \pix_icon('t/add', $str->questionbank, 'moodle', ['class' => 'iconsmall', 'title' => '']); if ($page) { $title = get_string('addquestionfrombanktopage', 'quiz', $page); } else { $title = get_string('addquestionfrombankatend', 'quiz'); } $attributes = ['class' => 'cm-edit-action questionbank', 'data-header' => $title, 'data-action' => 'questionbank', 'data-addonpage' => $page]; $actions['questionbank'] = new \action_menu_link_secondary($pageurl, $icon, $str->questionbank, $attributes); // Add a random question. if ($structure->can_add_random_questions()) { $returnurl = new \moodle_url('/mod/quiz/edit.php', ['cmid' => $structure->get_cmid(), 'data-addonpage' => $page]); $params = ['returnurl' => $returnurl, 'cmid' => $structure->get_cmid(), 'appendqnumstring' => 'addarandomquestion']; $url = new \moodle_url('/mod/quiz/edit.php', $params); $icon = new \pix_icon('t/add', $str->addarandomquestion, 'moodle', ['class' => 'iconsmall', 'title' => '']); $attributes = ['class' => 'cm-edit-action addarandomquestion', 'data-action' => 'addarandomquestion']; if ($page) { $title = get_string('addrandomquestiontopage', 'quiz', $page); } else { $title = get_string('addrandomquestionatend', 'quiz'); } $attributes = array_merge(['data-header' => $title, 'data-addonpage' => $page], $attributes); $actions['addarandomquestion'] = new \action_menu_link_secondary($url, $icon, $str->addarandomquestion, $attributes); } // Add a new section to the add_menu if possible. This is always added to the HTML // then hidden with CSS when no needed, so that as things are re-ordered, etc. with // Ajax it can be relevaled again when necessary. $params = ['cmid' => $structure->get_cmid(), 'addsectionatpage' => $page]; $actions['addasection'] = new \action_menu_link_secondary( new \moodle_url($pageurl, $params), new \pix_icon('t/add', $str->addasection, 'moodle', ['class' => 'iconsmall', 'title' => '']), $str->addasection, ['class' => 'cm-edit-action addasection', 'data-action' => 'addasection'] ); return $actions; } /** * Render the form that contains the data for adding a new question to the quiz. * * @param structure $structure object containing the structure of the quiz. * @param int $page the page number that this menu will add to. * @param \moodle_url $pageurl the canonical URL of this page. * @param array $pagevars the variables from {@link \question_edit_setup()}. * @return string HTML to output. */ protected function add_question_form(structure $structure, $page, \moodle_url $pageurl, array $pagevars) { $questioncategoryid = question_get_category_id_from_pagevars($pagevars); $output = html_writer::tag('input', null, ['type' => 'hidden', 'name' => 'returnurl', 'value' => $pageurl->out_as_local_url(false, ['addonpage' => $page])]); $output .= html_writer::tag('input', null, ['type' => 'hidden', 'name' => 'cmid', 'value' => $structure->get_cmid()]); $output .= html_writer::tag('input', null, ['type' => 'hidden', 'name' => 'appendqnumstring', 'value' => 'addquestion']); $output .= html_writer::tag('input', null, ['type' => 'hidden', 'name' => 'category', 'value' => $questioncategoryid]); return html_writer::tag('form', html_writer::div($output), ['class' => 'addnewquestion', 'method' => 'post', 'action' => new \moodle_url('/question/bank/editquestion/addquestion.php')]); } /** * Display a question. * * @param structure $structure object containing the structure of the quiz. * @param int $slot the first slot on the page we are outputting. * @param \moodle_url $pageurl the canonical URL of this page. * @return string HTML to output. */ public function question(structure $structure, int $slot, \moodle_url $pageurl) { global $DB; // Get the data required by the question_slot template. $slotid = $structure->get_slot_id_for_slot($slot); $question = $structure->get_question_in_slot($slot); $bank = $structure->get_source_bank($slot); if ($bank?->issharedbank && question_has_capability_on($question, 'view')) { $bankurl = (new \moodle_url('/question/edit.php', [ 'cmid' => $bank->cminfo->id, 'cat' => "{$question->category},{$question->contextid}", ] ))->out(false); } else { $bankurl = ''; } $output = ''; $output .= html_writer::start_tag('div'); if ($structure->can_be_edited()) { $output .= $this->question_move_icon($structure, $slot); } if ($structure->can_display_number_be_customised($slot)) { $questionnumber = $this->output->render($structure->make_slot_display_number_in_place_editable( $slotid, $structure->get_context())); } else { $questionnumber = $structure->get_displayed_number_for_slot($slot); } $data = [ 'slotid' => $slotid, 'canbeedited' => $structure->can_be_edited(), 'checkbox' => $this->get_checkbox_render($structure, $slot), 'questionnumber' => $this->question_number($questionnumber, $structure->get_slot_by_number($slot)->defaultnumber), 'questionname' => $this->get_question_name_for_slot($structure, $slot, $pageurl), 'questionicons' => $this->get_action_icon($structure, $slot, $pageurl), 'questiondependencyicon' => ($structure->can_be_edited() ? $this->question_dependency_icon($structure, $slot) : ''), 'versionselection' => false, 'draftversion' => $structure->get_question_in_slot($slot)->status == question_version_status::QUESTION_STATUS_DRAFT, 'bankname' => $bank?->cminfo->get_formatted_name(), 'issharedbank' => $bank?->issharedbank, 'bankurl' => $bankurl, ]; $data['versionoptions'] = []; if ($structure->get_slot_by_number($slot)->qtype !== 'random') { $data['versionselection'] = true; $data['versionoption'] = $structure->get_version_choices_for_slot($slot); } // Render the question slot template. $output .= $this->render_from_template('mod_quiz/question_slot', $data); $output .= html_writer::end_tag('div'); return $output; } /** * Get the checkbox render. * * @param structure $structure object containing the structure of the quiz. * @param int $slot the slot on the page we are outputting. * @return string HTML to output. */ public function get_checkbox_render(structure $structure, int $slot): string { $questionslot = $structure->get_displayed_number_for_slot($slot); $checkbox = new \core\output\checkbox_toggleall($this->togglegroup, false, [ 'id' => 'selectquestion-' . $slot, 'name' => 'selectquestion[]', 'classes' => 'select-multiple-checkbox', 'label' => get_string('selectquestionslot', 'quiz', $questionslot), 'labelclasses' => 'visually-hidden', ]); return $this->render($checkbox); } /** * Get the question name for the slot. * * @param structure $structure object containing the structure of the quiz. * @param int $slot the slot on the page we are outputting. * @param \moodle_url $pageurl the canonical URL of this page. * @return string HTML to output. */ public function get_question_name_for_slot(structure $structure, int $slot, \moodle_url $pageurl): string { // Display the link to the question (or do nothing if question has no url). if ($structure->get_question_type_for_slot($slot) === 'random') { $questionname = $this->random_question($structure, $slot, $pageurl); } else { $questionname = $this->question_name($structure, $slot, $pageurl); } return $questionname; } /** * Get the action icons render. * * @param structure $structure object containing the structure of the quiz. * @param int $slot the slot on the page we are outputting. * @param \moodle_url $pageurl the canonical URL of this page. * @return string HTML to output. */ public function get_action_icon(structure $structure, int $slot, \moodle_url $pageurl): string { // Action icons. $qtype = $structure->get_question_type_for_slot($slot); $slotinfo = $structure->get_slot_by_number($slot); $questionicons = ''; if ($qtype !== 'random' && question_bank::is_qtype_usable($qtype)) { $questionicons .= $this->question_preview_icon($structure->get_quiz(), $structure->get_question_in_slot($slot), null, null, $slotinfo->requestedversion ?: question_preview_options::ALWAYS_LATEST); } if ($structure->can_be_edited() && $structure->has_use_capability($slot)) { $questionicons .= $this->question_remove_icon($structure, $slot, $pageurl); } $questionicons .= $this->marked_out_of_field($structure, $slot); return $questionicons; } /** * Render the move icon. * * @param structure $structure object containing the structure of the quiz. * @param int $slot the first slot on the page we are outputting. * @return string The markup for the move action. */ public function question_move_icon(structure $structure, $slot) { return html_writer::link(new \moodle_url('#'), $this->pix_icon('i/dragdrop', get_string('move'), 'moodle', ['class' => 'iconsmall', 'title' => '']), ['class' => 'editing_move', 'data-action' => 'move'] ); } /** * Output the question number. * * @param string $editablenumber The, which may be an in-place editable. * @param string $uncustomisednumber The un-customised number number, or 'i'. * @return string HTML to output. */ public function question_number(string $editablenumber, string $uncustomisednumber) { if ($editablenumber !== get_string('infoshort', 'quiz')) { $editablenumber = html_writer::span(get_string('question'), 'accesshide') . ' ' . $editablenumber; $uncustomisednumber = html_writer::span(get_string('question'), 'accesshide') . ' ' . $uncustomisednumber; } return html_writer::tag('span', $editablenumber, ['class' => 'slotnumber unshuffled']) . html_writer::tag('span', $uncustomisednumber, ['class' => 'slotnumber shuffled']); } /** * Render the preview icon. * * @param \stdClass $quiz the quiz settings from the database. * @param \stdClass $questiondata which question to preview. * If ->questionid is set, that is used instead of ->id. * @param bool $label if true, show the preview question label after the icon * @param int $variant which question variant to preview (optional). * @param int $restartversion version to use when restarting the preview * @return string HTML to output. */ public function question_preview_icon($quiz, $questiondata, $label = null, $variant = null, $restartversion = null) { $question = clone($questiondata); if (!question_bank::is_qtype_usable($question->qtype)) { return ''; } if (isset($question->questionid)) { $question->id = $question->questionid; } $url = quiz_question_preview_url($quiz, $question, $variant, $restartversion); // Do we want a label? $strpreviewlabel = ''; if ($label) { $strpreviewlabel = ' ' . get_string('preview', 'quiz'); } // Build the icon. $strpreviewquestion = get_string('previewquestion', 'quiz'); $image = $this->pix_icon('t/preview', $strpreviewquestion); $action = new \popup_action('click', $url, 'questionpreview', \qbank_previewquestion\helper::question_preview_popup_params()); return $this->action_link($url, $image . $strpreviewlabel, $action, ['title' => $strpreviewquestion, 'class' => 'preview']); } /** * Render an icon to remove a question from the quiz. * * @param structure $structure object containing the structure of the quiz. * @param int $slot the first slot on the page we are outputting. * @param \moodle_url $pageurl the canonical URL of the edit page. * @return string HTML to output. */ public function question_remove_icon(structure $structure, $slot, $pageurl) { $url = new \moodle_url($pageurl, ['sesskey' => sesskey(), 'remove' => $slot]); $strdelete = get_string('delete'); $image = $this->pix_icon('t/delete', $strdelete); return $this->action_link($url, $image, null, ['title' => $strdelete, 'class' => 'cm-edit-action editing_delete', 'data-action' => 'delete']); } /** * Display an icon to split or join two pages of the quiz. * * @param structure $structure object containing the structure of the quiz. * @param int $slot the first slot on the page we are outputting. * @return string HTML to output. */ public function page_split_join_button($structure, $slot) { $insertpagebreak = !$structure->is_last_slot_on_page($slot); $url = new \moodle_url('repaginate.php', ['quizid' => $structure->get_quizid(), 'slot' => $slot, 'repag' => $insertpagebreak ? 2 : 1, 'sesskey' => sesskey()]); if ($insertpagebreak) { $title = get_string('addpagebreak', 'quiz'); $image = $this->image_icon('e/insert_page_break', $title); $action = 'addpagebreak'; } else { $title = get_string('removepagebreak', 'quiz'); $image = $this->image_icon('e/remove_page_break', $title); $action = 'removepagebreak'; } // Disable the link if quiz has attempts. $disabled = null; if (!$structure->can_be_edited()) { $disabled = 'disabled'; } return html_writer::span($this->action_link($url, $image, null, ['title' => $title, 'class' => 'page_split_join cm-edit-action', 'disabled' => $disabled, 'data-action' => $action]), 'page_split_join_wrapper'); } /** * Display the icon for whether this question can only be seen if the previous * one has been answered. * * @param structure $structure object containing the structure of the quiz. * @param int $slot the first slot on the page we are outputting. * @return string HTML to output. */ public function question_dependency_icon($structure, $slot) { $a = [ 'thisq' => $structure->get_displayed_number_for_slot($slot), 'previousq' => $structure->get_displayed_number_for_slot(max($slot - 1, 1)), ]; if ($structure->is_question_dependent_on_previous_slot($slot)) { $title = get_string('questiondependencyremove', 'quiz', $a); $image = $this->pix_icon('t/locked', get_string('questiondependsonprevious', 'quiz'), 'moodle', ['title' => '']); $action = 'removedependency'; } else { $title = get_string('questiondependencyadd', 'quiz', $a); $image = $this->pix_icon('t/unlocked', get_string('questiondependencyfree', 'quiz'), 'moodle', ['title' => '']); $action = 'adddependency'; } // Disable the link if quiz has attempts. $disabled = null; if (!$structure->can_be_edited()) { $disabled = 'disabled'; } $extraclass = ''; if (!$structure->can_question_depend_on_previous_slot($slot)) { $extraclass = ' question_dependency_cannot_depend'; } return html_writer::span($this->action_link('#', $image, null, ['title' => $title, 'class' => 'cm-edit-action', 'disabled' => $disabled, 'data-action' => $action]), 'question_dependency_wrapper' . $extraclass); } /** * Renders html to display a name with the link to the question on a quiz edit page * * If the user does not have permission to edi the question, it is rendered * without a link * * @param structure $structure object containing the structure of the quiz. * @param int $slot which slot we are outputting. * @param \moodle_url $pageurl the canonical URL of this page. * @return string HTML to output. */ public function question_name(structure $structure, $slot, $pageurl) { $output = ''; $question = $structure->get_question_in_slot($slot); $editurl = new \moodle_url('/question/bank/editquestion/question.php', [ 'returnurl' => $pageurl->out_as_local_url(), 'cmid' => $structure->get_cmid(), 'id' => $question->questionid]); $instancename = quiz_question_tostring($question); $qtype = question_bank::get_qtype($question->qtype, false); $namestr = $qtype->local_name(); $icon = $this->pix_icon('icon', $namestr, $qtype->plugin_name(), ['title' => $namestr, 'class' => 'activityicon', 'alt' => $namestr]); $editicon = $this->pix_icon('t/edit', '', 'moodle', ['title' => '']); // Need plain question name without html tags for link title. $title = shorten_text(format_string($question->name), 100); // If the question is invalid, don't show the link as it won't work. if (!question_bank::is_qtype_usable($question->qtype)) { $output .= html_writer::span($title); $output .= html_writer::span( get_string('invalidquestiontype', 'question', $question->originalqtype), 'badge bg-danger text-white ms-3' ); } else { $canedit = question_has_capability_on($question->questionid, 'edit'); $instancename = $canedit ? $editicon . $instancename : $instancename; // Display the link, if the user has permission to edit. Otherwise, just display the name and icon. $questionname = $icon . html_writer::tag('span', $instancename, ['class' => 'instancename']); if ($canedit) { $output .= html_writer::link( $editurl, $questionname, [ 'title' => get_string('editquestion', 'quiz') . ' ' . $title, ], ); } else { $output .= $questionname; } } return $output; } /** * Renders html to display a random question the link to edit the configuration * and also to see that category in the question bank. * * @param structure $structure object containing the structure of the quiz. * @param int $slotnumber which slot we are outputting. * @param \moodle_url $pageurl the canonical URL of this page. * @return string HTML to output. */ public function random_question(structure $structure, $slotnumber, $pageurl) { $question = $structure->get_question_in_slot($slotnumber); $slot = $structure->get_slot_by_number($slotnumber); $temp = clone($question); $temp->questiontext = ''; $temp->name = $structure->describe_random_slot($slot->id); $instancename = quiz_question_tostring($temp); if (strpos($instancename, structure::MISSING_QUESTION_CATEGORY_PLACEHOLDER) !== false) { $label = html_writer::span( get_string('missingcategory', 'mod_quiz'), 'badge bg-danger text-white h-50' ); $instancename = str_replace(structure::MISSING_QUESTION_CATEGORY_PLACEHOLDER, $label, $instancename); } $configuretitle = get_string('configurerandomquestion', 'quiz'); $qtype = question_bank::get_qtype($question->qtype, false); $namestr = $qtype->local_name(); $icon = $this->pix_icon('icon', $namestr, $qtype->plugin_name(), ['class' => 'icon activityicon']); $editicon = $this->pix_icon('t/edit', $configuretitle, 'moodle', ['title' => '']); $qbankurlparams = [ 'cmid' => $structure->get_source_bank($slotnumber)->cminfo->id, 'filter' => json_encode($slot->filtercondition['filter']), ]; // If this is a random question, display a link to show the questions // selected from in the question bank. $qbankurl = new \moodle_url('/question/edit.php', $qbankurlparams); $qbanklink = ' ' . \html_writer::link($qbankurl, get_string('seequestions', 'quiz'), ['class' => 'mod_quiz_random_qbank_link']); $editlink = html_writer::link( $pageurl->out(), $icon . $editicon, [ 'title' => $configuretitle, 'data-action' => 'editrandomquestion', 'data-slotid' => $slot->id, 'data-header' => get_string('randomediting', 'mod_quiz'), ], ); return $editlink . ' ' . $instancename . ' ' . $qbanklink; } /** * Display the 'marked out of' information for a question. * Along with the regrade action. * @param structure $structure object containing the structure of the quiz. * @param int $slot which slot we are outputting. * @return string HTML to output. */ public function marked_out_of_field(structure $structure, $slot) { if (!$structure->is_real_question($slot)) { $output = html_writer::span('', 'instancemaxmark decimalplaces_' . $structure->get_decimal_places_for_question_marks()); $output .= html_writer::span( $this->pix_icon('spacer', '', 'moodle', ['class' => 'editicon visibleifjs', 'title' => '']), 'editing_maxmark'); return html_writer::span($output, 'instancemaxmarkcontainer infoitem'); } $output = html_writer::span($structure->formatted_question_grade($slot), 'instancemaxmark decimalplaces_' . $structure->get_decimal_places_for_question_marks(), ['title' => get_string('maxmark', 'quiz')]); $output .= html_writer::span( html_writer::link( new \moodle_url('#'), $this->pix_icon('t/editstring', '', 'moodle', ['class' => 'editicon visibleifjs', 'title' => '']), [ 'class' => 'editing_maxmark', 'data-action' => 'editmaxmark', 'title' => get_string('editmaxmark', 'quiz'), ] ) ); return html_writer::span($output, 'instancemaxmarkcontainer'); } /** * Renders the question chooser. * * @param renderable * @return string */ public function render_question_chooser(renderable $chooser) { return $this->render_from_template('mod_quiz/question_chooser', $chooser->export_for_template($this)); } /** * Render the question type chooser dialogue. * @return string HTML to output. */ public function question_chooser() { $chooser = \mod_quiz\output\question_chooser::get($this->page->course, [], null); $container = html_writer::div($this->render($chooser), '', ['id' => 'qtypechoicecontainer']); return html_writer::div($container, 'createnewquestion'); } /** * Render the contents of the question bank pop-up in its initial state, * when it just contains a loading progress indicator. * @return string HTML to output. */ public function question_bank_loading() { return html_writer::div($this->pix_icon('i/loading', get_string('loading')), 'questionbankloading'); } /** * Initialise the JavaScript for the general editing. (JavaScript for popups * is handled with the specific code for those.) * * @param structure $structure object containing the structure of the quiz. * @param \core_question\local\bank\question_edit_contexts $contexts the relevant question bank contexts. * @param array $pagevars the variables from {@link \question_edit_setup()}. * @param \moodle_url $pageurl the canonical URL of this page. * @return bool Always returns true */ protected function initialise_editing_javascript(structure $structure, \core_question\local\bank\question_edit_contexts $contexts, array $pagevars, \moodle_url $pageurl) { $config = new \stdClass(); $config->resourceurl = '/mod/quiz/edit_rest.php'; $config->sectionurl = '/mod/quiz/edit_rest.php'; $config->pageparams = []; $config->questiondecimalpoints = $structure->get_decimal_places_for_question_marks(); $config->pagehtml = $this->new_page_template($structure, $contexts, $pagevars, $pageurl); $config->addpageiconhtml = $this->add_page_icon_template($structure); $this->page->requires->yui_module('moodle-mod_quiz-toolboxes', 'M.mod_quiz.init_resource_toolbox', [[ 'courseid' => $structure->get_courseid(), 'quizid' => $structure->get_quizid(), 'ajaxurl' => $config->resourceurl, 'config' => $config, ]] ); unset($config->pagehtml); unset($config->addpageiconhtml); $this->page->requires->strings_for_js(['areyousureremoveselected'], 'quiz'); $this->page->requires->yui_module('moodle-mod_quiz-toolboxes', 'M.mod_quiz.init_section_toolbox', [[ 'courseid' => $structure, 'quizid' => $structure->get_quizid(), 'ajaxurl' => $config->sectionurl, 'config' => $config, ]] ); $this->page->requires->yui_module('moodle-mod_quiz-dragdrop', 'M.mod_quiz.init_section_dragdrop', [[ 'courseid' => $structure, 'quizid' => $structure->get_quizid(), 'ajaxurl' => $config->sectionurl, 'config' => $config, ]], null, true); $this->page->requires->yui_module('moodle-mod_quiz-dragdrop', 'M.mod_quiz.init_resource_dragdrop', [[ 'courseid' => $structure, 'quizid' => $structure->get_quizid(), 'ajaxurl' => $config->resourceurl, 'config' => $config, ]], null, true); // Require various strings for the command toolbox. $this->page->requires->strings_for_js([ 'clicktohideshow', 'deletechecktype', 'deletechecktypename', 'edittitle', 'edittitleinstructions', 'emptydragdropregion', 'hide', 'move', 'movecontent', 'moveleft', 'movesection', 'page', 'question', 'selectall', 'show', 'tocontent', ], 'moodle'); $this->page->requires->strings_for_js([ 'addpagebreak', 'cannotremoveallsectionslots', 'cannotremoveslots', 'confirmremovesectionheading', 'confirmremovequestion', 'dragtoafter', 'dragtostart', 'numquestionsx', 'sectionheadingedit', 'sectionheadingremove', 'sectionnoname', 'removepagebreak', 'questiondependencyadd', 'questiondependencyfree', 'questiondependencyremove', 'questiondependsonprevious', ], 'quiz'); foreach (question_bank::get_all_qtypes() as $qtype => $notused) { $this->page->requires->string_for_js('pluginname', 'qtype_' . $qtype); } return true; } /** * HTML for a page, with ids stripped, so it can be used as a javascript template. * * @param structure $structure object containing the structure of the quiz. * @param \core_question\local\bank\question_edit_contexts $contexts the relevant question bank contexts. * @param array $pagevars the variables from {@link \question_edit_setup()}. * @param \moodle_url $pageurl the canonical URL of this page. * @return string HTML for a new page. */ protected function new_page_template(structure $structure, \core_question\local\bank\question_edit_contexts $contexts, array $pagevars, \moodle_url $pageurl) { if (!$structure->has_questions()) { return ''; } $pagehtml = $this->page_row($structure, 1, $contexts, $pagevars, $pageurl); // Normalise the page number. $pagenumber = $structure->get_page_number_for_slot(1); $strcontexts = []; $strcontexts[] = 'page-'; $strcontexts[] = get_string('page') . ' '; $strcontexts[] = 'addonpage%3D'; $strcontexts[] = 'addonpage='; $strcontexts[] = 'addonpage="'; $strcontexts[] = get_string('addquestionfrombanktopage', 'quiz', ''); $strcontexts[] = 'data-addonpage%3D'; $strcontexts[] = 'action-menu-'; foreach ($strcontexts as $strcontext) { $pagehtml = str_replace($strcontext . $pagenumber, $strcontext . '%%PAGENUMBER%%', $pagehtml); } return $pagehtml; } /** * HTML for a page, with ids stripped, so it can be used as a javascript template. * * @param structure $structure object containing the structure of the quiz. * @return string HTML for a new icon */ protected function add_page_icon_template(structure $structure) { if (!$structure->has_questions()) { return ''; } $html = $this->page_split_join_button($structure, 1); return str_replace('&slot=1&', '&slot=%%SLOT%%&', $html); } /** * Return the contents of the question bank, to be displayed in the question-bank pop-up. * * @param \mod_quiz\question\bank\custom_view $questionbank the question bank view object. * @param array $pagevars the variables from {@link \question_edit_setup()}. * @return string HTML to output / send back in response to an AJAX request. */ public function question_bank_contents(\mod_quiz\question\bank\custom_view $questionbank, array $pagevars) { $qbank = $questionbank->render($pagevars, 'editq'); return html_writer::div(html_writer::div($qbank, 'bd'), 'questionbankformforpopup'); } } PK9B\yY}classes/output/view_page.phpnu[. namespace mod_quiz\output; use mod_quiz\access_manager; use mod_quiz\form\preflight_check_form; use mod_quiz\quiz_attempt; use moodle_url; /** * This class captures all the various information to render the front page of the quiz activity. * * This class is not currently renderable or templatable, but it very nearly could be, * which is why it is in the output namespace. It is used to send data to the renderer. * * @package mod_quiz * @category output * @copyright 2011 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class view_page { /** @var array $infomessages of messages with information to display about the quiz. */ public $infomessages; /** @var array $attempts contains all the user's attempts at this quiz. */ public $attempts; /** @var quiz_attempt[] $attemptobjs objects corresponding to $attempts. */ public $attemptobjs; /** @var list_of_attempts list of past attempts for rendering. */ public $attemptslist; /** @var access_manager $accessmanager contains various access rules. */ public $accessmanager; /** @var bool $canreviewmine whether the current user has the capability to * review their own attempts. */ public $canreviewmine; /** @var bool $canedit whether the current user has the capability to edit the quiz. */ public $canedit; /** @var moodle_url $editurl the URL for editing this quiz. */ public $editurl; /** @var int $attemptcolumn contains the number of attempts done. */ public $attemptcolumn; /** @var int $gradecolumn contains the grades of any attempts. */ public $gradecolumn; /** @var int $markcolumn contains the marks of any attempt. */ public $markcolumn; /** @var int $overallstats contains all marks for any attempt. */ public $overallstats; /** @var string $feedbackcolumn contains any feedback for and attempt. */ public $feedbackcolumn; /** @var string $timenow contains a timestamp in string format. */ public $timenow; /** @var int $numattempts contains the total number of attempts. */ public $numattempts; /** @var float $mygrade contains the user's final grade for a quiz. */ public $mygrade; /** @var bool $moreattempts whether this user is allowed more attempts. */ public $moreattempts; /** @var int $mygradeoverridden contains an overriden grade. */ public $mygradeoverridden; /** @var string $gradebookfeedback contains any feedback for a gradebook. */ public $gradebookfeedback; /** @var bool $unfinished contains 1 if an attempt is unfinished. */ public $unfinished; /** @var stdClass $lastfinishedattempt the last attempt from the attempts array. */ public $lastfinishedattempt; /** @var array $preventmessages of messages telling the user why they can't * attempt the quiz now. */ public $preventmessages; /** @var string $buttontext caption for the start attempt button. If this is null, show no * button, or if it is '' show a back to the course button. */ public $buttontext; /** @var moodle_url $startattempturl URL to start an attempt. */ public $startattempturl; /** @var preflight_check_form|null $preflightcheckform confirmation form that must be * submitted before an attempt is started, if required. */ public $preflightcheckform; /** @var moodle_url $startattempturl URL for any Back to the course button. */ public $backtocourseurl; /** @var bool $showbacktocourse should we show a back to the course button? */ public $showbacktocourse; /** @var bool whether the attempt must take place in a popup window. */ public $popuprequired; /** @var array options to use for the popup window, if required. */ public $popupoptions; /** @var bool $quizhasquestions whether the quiz has any questions. */ public $quizhasquestions; } PK9B\'-classes/output/navigation_question_button.phpnu[. namespace mod_quiz\output; use moodle_url; use renderable; /** * Represents a single link in the navigation panel. * * @package mod_quiz * @category output * @copyright 2011 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class navigation_question_button implements renderable { /** @var string id="..." to add to the HTML for this button. */ public $id; /** @var bool, if this is a real question, not an info item. */ public $isrealquestion; /** @var string number to display in this button. Either the question number of 'i'. */ public $number; /** @var string class to add to the class="" attribute to represnt the question state. */ public $stateclass; /** @var string Textual description of the question state, e.g. to use as a tool tip. */ public $statestring; /** @var int the page number this question is on. */ public $page; /** @var bool true if this question is on the current page. */ public $currentpage; /** @var bool true if this question has been flagged. */ public $flagged; /** @var moodle_url the link this button goes to, or null if there should not be a link. */ public $url; /** @var int QUIZ_NAVMETHOD_FREE or QUIZ_NAVMETHOD_SEQ. */ public $navmethod; } PK9B\JL-classes/output/navigation_section_heading.phpnu[. namespace mod_quiz\output; use renderable; /** * Represents a heading in the navigation panel. * * @package mod_quiz * @category output * @copyright 2015 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class navigation_section_heading implements renderable { /** @var string the heading text. */ public $heading; /** * Constructor. * @param string $heading the heading text */ public function __construct($heading) { $this->heading = $heading; } } PK9B\\sr3 3 #classes/output/list_of_attempts.phpnu[. namespace mod_quiz\output; use core\output\named_templatable; use mod_quiz\quiz_attempt; use renderable; use renderer_base; /** * Display summary information about a list of attempts. * * This is used on the front page of the quiz (view.php). * * @package mod_quiz * @copyright 2024 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class list_of_attempts implements renderable, named_templatable { /** @var int time to consider as now. */ protected int $timenow; /** @var quiz_attempt[] The list of attempts to summarise. */ protected array $attempts = []; /** * Constructor. * * @param int $timenow time that is now. */ public function __construct(int $timenow) { $this->timenow = $timenow; } /** * Add an event to the list. * * @param quiz_attempt $attemptobj */ public function add_attempt(quiz_attempt $attemptobj): void { $this->attempts[] = $attemptobj; } public function export_for_template(renderer_base $output): array { $templatecontext = [ 'hasattempts' => !empty($this->attempts), 'attempts' => [], ]; foreach ($this->attempts as $attemptobj) { $displayoptions = $attemptobj->get_display_options(true); $templatecontext['attempts'][] = (object) [ 'name' => get_string('attempt', 'mod_quiz', $attemptobj->get_attempt_number()), 'summarydata' => attempt_summary_information::create_for_attempt( $attemptobj, $displayoptions)->export_for_template($output), 'reviewlink' => $attemptobj->get_access_manager($this->timenow)->make_review_link( $attemptobj->get_attempt(), $displayoptions, $output), ]; } return $templatecontext; } public function get_template_name(\renderer_base $renderer): string { // Only reason we are forced to implement this is that we want the quiz renderer // passed to export_for_template, not a core_renderer. return 'mod_quiz/list_of_attempts'; } } PK9B\Au*classes/output/links_to_other_attempts.phpnu[. namespace mod_quiz\output; use renderable; /** * Represents the list of links to other attempts * * @package mod_quiz * @category output * @copyright 2011 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class links_to_other_attempts implements renderable { /** * @var array The list of links. string attempt number => one of three things: * - null if this is the current attempt, and so should not be linked. (Just the number is output.) * - moodle_url if this is a different attempt. (Output as a link to the URL with the number as link text.) * - a renderable, in which case the results of rendering the renderable is output. * (The third option is used by {@see quiz_attempt::links_to_other_redos()}.) */ public $links = []; } PK9B\=hh$classes/output/edit_grading_page.phpnu[. namespace mod_quiz\output; use mod_quiz\structure; use renderable; use renderer_base; use templatable; /** * Represents the page where teachers can set up additional grade items. * * @package mod_quiz * @category output * @copyright 2023 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class edit_grading_page implements renderable, templatable { /** * Constructor. * * @param structure $structure */ public function __construct( /** @var structure structure of the quiz for which to display the grade edit page. */ protected readonly structure $structure, ) { } public function export_for_template(renderer_base $output) { global $PAGE; /** @var edit_renderer $editrenderer */ $editrenderer = $PAGE->get_renderer('mod_quiz', 'edit'); // Get the list of grade items, but to be the choices for a slot, and the list to be edited. $gradeitemchoices = [ 0 => [ 'id' => 0, 'choice' => get_string('gradeitemnoneselected', 'quiz'), 'isselected' => false, ], ]; $selectdgradeitemchoices = []; $gradeitems = []; foreach ($this->structure->get_grade_items() as $gradeitem) { $gradeitem = clone($gradeitem); unset($gradeitem->quizid); $gradeitem->displayname = format_string($gradeitem->name); $gradeitem->isused = $this->structure->is_grade_item_used($gradeitem->id); $gradeitem->summarks = $gradeitem->isused ? $this->structure->formatted_grade_item_sum_marks($gradeitem->id) : '-'; $gradeitems[] = $gradeitem; $gradeitemchoices[$gradeitem->id] = (object) [ 'id' => $gradeitem->id, 'choice' => $gradeitem->displayname, 'isselected' => false, ]; $selectdgradeitemchoices[$gradeitem->id] = clone($gradeitemchoices[$gradeitem->id]); $selectdgradeitemchoices[$gradeitem->id]->isselected = true; } // Get the list of quiz sections. $sections = []; foreach ($this->structure->get_sections() as $section) { $sections[$section->id] = (object) [ 'displayname' => $section->heading ? format_string($section->heading) : get_string('sectionnoname', 'quiz'), 'slots' => [], ]; } // Add the relevant slots ot each section. foreach ($this->structure->get_slots() as $slot) { if (!$this->structure->is_real_question($slot->slot)) { continue; } // Mark the right choice as selected. $choices = $gradeitemchoices; if ($slot->quizgradeitemid) { $choices[$slot->quizgradeitemid] = $selectdgradeitemchoices[$slot->quizgradeitemid]; } $sections[$slot->section->id]->slots[] = (object) [ 'id' => $slot->id, 'displaynumber' => $this->structure->get_displayed_number_for_slot($slot->slot), 'displayname' => $editrenderer->get_question_name_for_slot( $this->structure, $slot->slot, $PAGE->url), 'maxmark' => $this->structure->formatted_question_grade($slot->slot), 'choices' => array_values($choices), ]; } return [ 'quizid' => $this->structure->get_quizid(), 'hasgradeitems' => !empty($gradeitems), 'gradeitems' => $gradeitems, 'hasslots' => $this->structure->has_questions(), 'sections' => array_values($sections), 'hasmultiplesections' => count($sections) > 1, 'nogradeitems' => ['message' => get_string('gradeitemsnoneyet', 'quiz')], 'noslots' => ['message' => get_string('gradeitemnoslots', 'quiz')], ]; } } PK9B\y(classes/output/navigation_panel_base.phpnu[. namespace mod_quiz\output; use mod_quiz\quiz_attempt; use moodle_url; use question_attempt; use question_display_options; use question_state; use renderable; use user_picture; /** * Represents the navigation panel, and builds a {@see block_contents} to allow it to be output. * * This class is not currently renderable or templatable, but it probably should be in the future, * which is why it is already in the output namespace. * * @package mod_quiz * @category output * @copyright 2008 Tim Hunt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ abstract class navigation_panel_base { /** @var quiz_attempt */ protected $attemptobj; /** @var question_display_options */ protected $options; /** @var integer */ protected $page; /** @var boolean */ protected $showall; /** * Constructor. * * @param quiz_attempt $attemptobj construct the panel for this attempt. * @param question_display_options $options display options in force. * @param int $page which page of the quiz attempt is being shown, -1 if all. * @param bool $showall whether all pages are being shown at once. */ public function __construct(quiz_attempt $attemptobj, question_display_options $options, $page, $showall) { $this->attemptobj = $attemptobj; $this->options = $options; $this->page = $page; $this->showall = $showall; } /** * Get the buttons and section headings to go in the quiz navigation block. * * @return renderable[] the buttons, possibly interleaved with section headings. */ public function get_question_buttons() { $buttons = []; foreach ($this->attemptobj->get_slots() as $slot) { $heading = $this->attemptobj->get_heading_before_slot($slot); if (!is_null($heading)) { $sections = $this->attemptobj->get_quizobj()->get_sections(); if (!(empty($heading) && count($sections) == 1)) { $buttons[] = new navigation_section_heading(format_string($heading)); } } $qa = $this->attemptobj->get_question_attempt($slot); $showcorrectness = $this->options->correctness && $qa->has_marks(); $button = new navigation_question_button(); $button->id = 'quiznavbutton' . $slot; $button->isrealquestion = $this->attemptobj->is_real_question($slot); $button->number = $this->attemptobj->get_question_number($slot); $button->stateclass = $qa->get_state_class($showcorrectness); $button->navmethod = $this->attemptobj->get_navigation_method(); if (!$showcorrectness && $button->stateclass === 'notanswered') { $button->stateclass = 'complete'; } $button->statestring = $this->get_state_string($qa, $showcorrectness); $button->page = $this->attemptobj->get_question_page($slot); $button->currentpage = $this->showall || $button->page == $this->page; $button->flagged = $qa->is_flagged(); $button->url = $this->get_question_url($slot); if ($this->attemptobj->is_blocked_by_previous_question($slot)) { $button->url = null; $button->stateclass = 'blocked'; $button->statestring = get_string('questiondependsonprevious', 'quiz'); } $buttons[] = $button; } return $buttons; } /** * Get the human-readable description of the current state of a particular question. * * @param question_attempt $qa the attempt at the question of interest. * @param bool $showcorrectness whether the current use is allowed to see if they have got the question right. * @return string Human-readable description of the state. */ protected function get_state_string(question_attempt $qa, $showcorrectness) { if ($qa->get_question(false)->length > 0) { return $qa->get_state_string($showcorrectness); } // Special case handling for 'information' items. if ($qa->get_state() == question_state::$todo) { return get_string('notyetviewed', 'quiz'); } else { return get_string('viewed', 'quiz'); } } /** * Hook for subclasses to override to do output above the question buttons. * * @param renderer $output the quiz renderer to use. * @return string HTML to output. */ public function render_before_button_bits(renderer $output) { return ''; } /** * Hook that subclasses must override to do output after the question buttons. * * @param renderer $output the quiz renderer to use. * @return string HTML to output. */ abstract public function render_end_bits(renderer $output); /** * Render the restart preview button. * * @param renderer $output the quiz renderer to use. * @return string HTML to output. */ protected function render_restart_preview_link($output) { if (!$this->attemptobj->is_own_preview()) { return ''; } return $output->restart_preview_button(new moodle_url( $this->attemptobj->start_attempt_url(), ['forcenew' => true])); } /** * Get the URL to navigate to a particular question. * * @param int $slot slot number, to identify the question. * @return moodle_url|null URL if the user can navigate there, or null if they cannot. */ abstract protected function get_question_url($slot); /** * Get the user picture which should be displayed, if required. * * @return user_picture|null */ public function user_picture() { global $DB, $PAGE; if ($this->attemptobj->get_quiz()->showuserpicture == QUIZ_SHOWIMAGE_NONE) { return null; } $user = $DB->get_record('user', ['id' => $this->attemptobj->get_userid()]); $userpicture = new user_picture($user); $userpicture->courseid = $this->attemptobj->get_courseid(); if ($PAGE->pagelayout === 'secure') { $userpicture->link = false; } if ($this->attemptobj->get_quiz()->showuserpicture == QUIZ_SHOWIMAGE_LARGE) { $userpicture->size = true; } return $userpicture; } /** * Return 'allquestionsononepage' as CSS class name when $showall is set, * otherwise, return 'multipages' as CSS class name. * * @return string, CSS class name */ public function get_button_container_class() { // Quiz navigation is set on 'Show all questions on one page'. if ($this->showall) { return 'allquestionsononepage'; } // Quiz navigation is set on 'Show one page at a time'. return 'multipages'; } } PK9B\pF#{ { #classes/output/edit_nav_actions.phpnu[. namespace mod_quiz\output; use moodle_url; use renderable; use renderer_base; use templatable; use url_select; /** * Represents the tertiary navigation around the quiz edit pages. * * @package mod_quiz * @copyright 2023 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class edit_nav_actions implements renderable, templatable { /** @var string option for $whichpage argument to the constructor. */ const SUMMARY = 'summary'; /** @var string option for $whichpage argument to the constructor. */ const GRADING = 'grading'; /** * overrides_action constructor. * * @param int $cmid The course module id. * @param string $whichpage self::SUMMARY (edit.php) or self::GRADING (editgrading.php). */ public function __construct( /** @var int The course module ID. */ protected readonly int $cmid, /** @var string which page this is. Either self::SUMMARY (edit.php) or self::GRADING (editgrading.php). */ protected readonly string $whichpage, ) { } public function export_for_template(renderer_base $output): array { // Build the navigation drop-down. $questionsurl = new moodle_url('/mod/quiz/edit.php', ['cmid' => $this->cmid]); $gradeitemsetupurl = new moodle_url('/mod/quiz/editgrading.php', ['cmid' => $this->cmid]); $menu = [ $questionsurl->out(false) => get_string('questions', 'quiz'), $gradeitemsetupurl->out(false) => get_string('gradeitemsetup', 'quiz'), ]; $overridesnav = new url_select( $menu, $this->whichpage === self::SUMMARY ? $questionsurl->out(false) : $gradeitemsetupurl->out(false), null ); $overridesnav->set_label(get_string('quizsetupnavigation', 'quiz'), ['class' => 'visually-hidden']); return [ 'navmenu' => $overridesnav->export_for_template($output), ]; } } PK9B\ʹ$classes/output/overrides_actions.phpnu[. namespace mod_quiz\output; use moodle_url; use renderable; use renderer_base; use templatable; use url_select; /** * Render overrides action in the quiz secondary navigation * * The user/group overrides are now handled in the secondary navigation. * This class provides the data for the templates to handle the data for * overrides tab. * * @package mod_quiz * @copyright 2021 Sujith Haridasan * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class overrides_actions implements renderable, templatable { /** @var int The course module ID. */ private $cmid; /** @var string The mode passed for the overrides url. */ private $mode; /** @var bool Check if the user have capabilities to list overrides. */ private $canedit; /** @var bool Should the add override button be enabled or disabled. */ private $addenabled; /** * overrides_action constructor. * * @param int $cmid The course module id. * @param string $mode The mode passed for the overrides url. * @param bool $canedit Does the user have capabilities to list overrides. * @param bool $addenabled Whether the add button should be enabled or disabled. */ public function __construct(int $cmid, string $mode, bool $canedit, bool $addenabled) { $this->cmid = $cmid; $this->mode = $mode; $this->canedit = $canedit; $this->addenabled = $addenabled; } /** * Create the add override button. * * @param \renderer_base $output an instance of the quiz renderer. * @return \single_button the button, ready to reander. */ public function create_add_button(\renderer_base $output): \single_button { $addoverrideurl = new moodle_url('/mod/quiz/overrideedit.php', ['cmid' => $this->cmid, 'action' => 'add' . $this->mode]); if ($this->mode === 'group') { $label = get_string('addnewgroupoverride', 'quiz'); } else { $label = get_string('addnewuseroverride', 'quiz'); } $addoverridebutton = new \single_button($addoverrideurl, $label, 'get', \single_button::BUTTON_PRIMARY); if (!$this->addenabled) { $addoverridebutton->disabled = true; } return $addoverridebutton; } public function export_for_template(renderer_base $output): array { global $PAGE; $templatecontext = []; // Build the navigation drop-down. $useroverridesurl = new moodle_url('/mod/quiz/overrides.php', ['cmid' => $this->cmid, 'mode' => 'user']); $groupoverridesurl = new moodle_url('/mod/quiz/overrides.php', ['cmid' => $this->cmid, 'mode' => 'group']); $menu = [ $useroverridesurl->out(false) => get_string('useroverrides', 'quiz'), $groupoverridesurl->out(false) => get_string('groupoverrides', 'quiz') ]; $overridesnav = new url_select($menu, $PAGE->url->out(false), null, 'quizoverrides'); $templatecontext['overridesnav'] = $overridesnav->export_for_template($output); // Build the add button - but only if the user can edit. if ($this->canedit) { $templatecontext['addoverridebutton'] = $this->create_add_button($output)->export_for_template($output); } return $templatecontext; } } PK9B\0Fclasses/repaginate.phpnu[. namespace mod_quiz; use stdClass; defined('MOODLE_INTERNAL') || die(); /** * The repaginate class will rearrange questions in pages. * * The quiz setting allows users to write quizzes with one question per page, * n questions per page, or all questions on one page. * * @package mod_quiz * @copyright 2014 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class repaginate { /** @var int means join pages. */ const LINK = 1; /** @var int means split pages. */ const UNLINK = 2; /** @var int the id of the quiz being manipulated. */ private $quizid; /** @var array the quiz_slots for that quiz. */ private $slots; /** * Constructor. * @param int $quizid the id of the quiz being manipulated. * @param stdClass[] $slots the quiz_slots for that quiz. */ public function __construct($quizid = 0, $slots = null) { global $DB; $this->quizid = $quizid; if (!$this->quizid) { $this->slots = []; } if (!$slots) { $this->slots = $DB->get_records('quiz_slots', ['quizid' => $this->quizid], 'slot'); } else { $this->slots = $slots; } } /** * Repaginate a given slot with the given pagenumber. * @param stdClass $slot * @param int $newpagenumber * @return stdClass */ protected function repaginate_this_slot($slot, $newpagenumber) { $newslot = clone($slot); $newslot->page = $newpagenumber; return $newslot; } /** * Return current slot object. * @param array $slots * @param int $slotnumber * @return stdClass $slot */ protected function get_this_slot($slots, $slotnumber) { foreach ($slots as $slot) { if ($slot->slot == $slotnumber) { return $slot; } } return null; } /** * Return array of slots with slot number as key * @param stdClass[] $slots * @return stdClass[] */ protected function get_slots_by_slot_number($slots) { if (!$slots) { return []; } $newslots = []; foreach ($slots as $slot) { $newslots[$slot->slot] = $slot; } return $newslots; } /** * Return array of slots with slot id as key * @param stdClass[] $slots * @return stdClass[] */ protected function get_slots_by_slotid($slots) { if (!$slots) { return []; } $newslots = []; foreach ($slots as $slot) { $newslots[$slot->id] = $slot; } return $newslots; } /** * Repaginate, update DB and slots object. * * @param int $nextslotnumber * @param int $type repaginate::LINK or repaginate::UNLINK. */ public function repaginate_slots($nextslotnumber, $type) { global $DB; $this->slots = $DB->get_records('quiz_slots', ['quizid' => $this->quizid], 'slot'); $nextslot = null; $newslots = []; foreach ($this->slots as $slot) { if ($slot->slot < $nextslotnumber) { $newslots[$slot->id] = $slot; } else if ($slot->slot == $nextslotnumber) { $nextslot = $this->repaginate_next_slot($nextslotnumber, $type); // Update DB. $DB->update_record('quiz_slots', $nextslot, true); // Update returning object. $newslots[$slot->id] = $nextslot; } } if ($nextslot) { $newslots = array_merge($newslots, $this->repaginate_the_rest($this->slots, $nextslotnumber, $type)); $this->slots = $this->get_slots_by_slotid($newslots); } } /** * Repaginate next slot and return the modified slot object. * * @param int $nextslotnumber * @param int $type repaginate::LINK or repaginate::UNLINK. * @return stdClass|null */ public function repaginate_next_slot($nextslotnumber, $type) { $currentslotnumber = $nextslotnumber - 1; if (!($currentslotnumber && $nextslotnumber)) { return null; } $currentslot = $this->get_this_slot($this->slots, $currentslotnumber); $nextslot = $this->get_this_slot($this->slots, $nextslotnumber); if ($type === self::LINK) { return $this->repaginate_this_slot($nextslot, $currentslot->page); } else if ($type === self::UNLINK) { return $this->repaginate_this_slot($nextslot, $nextslot->page + 1); } return null; } /** * Return the slots with the new pagination, regardless of current pagination. * @param stdClass[] $slots the slots to repaginate. * @param int $number number of question per page * @return stdClass[] the updated slots. */ public function repaginate_n_question_per_page($slots, $number) { $slots = $this->get_slots_by_slot_number($slots); $newslots = []; $count = 0; $page = 1; foreach ($slots as $slot) { for (; $page < ($number + $count + 1); $page++) { if ($slot->slot >= $page) { $slot->page = $page; $count++; } } $newslots[$slot->id] = $slot; } return $newslots; } /** * Repaginate the rest. * * @param stdClass[] $quizslots * @param int $slotfrom * @param int $type * @param bool $dbupdate * @return stdClass[] */ public function repaginate_the_rest($quizslots, $slotfrom, $type, $dbupdate = true) { global $DB; if (!$quizslots) { return null; } $newslots = []; foreach ($quizslots as $slot) { if ($type == self::LINK) { if ($slot->slot <= $slotfrom) { continue; } $slot->page = $slot->page - 1; } else if ($type == self::UNLINK) { if ($slot->slot <= $slotfrom - 1) { continue; } $slot->page = $slot->page + 1; } // Update DB. if ($dbupdate) { $DB->update_record('quiz_slots', $slot); } $newslots[$slot->id] = $slot; } return $newslots; } } PK9B\rb 'classes/event/attempt_becameoverdue.phpnu[. /** * The mod_quiz attempt became overdue event. * * @package mod_quiz * @copyright 2013 Adrian Greeve * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace mod_quiz\event; defined('MOODLE_INTERNAL') || die(); /** * The mod_quiz attempt became overdue event class. * * Please note that the name of this event is not following the event naming convention. * Its name should not be used as a reference for other events to be created. * * @property-read array $other { * Extra information about event. * * - int submitterid: id of submitter (null when trigged by CLI script). * - int quizid: (optional) the id of the quiz. * } * * @package mod_quiz * @since Moodle 2.6 * @copyright 2013 Adrian Greeve * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class attempt_becameoverdue extends \core\event\base { /** * Init method. */ protected function init() { $this->data['objecttable'] = 'quiz_attempts'; $this->data['crud'] = 'u'; $this->data['edulevel'] = self::LEVEL_PARTICIPATING; } /** * Returns description of what happened. * * @return string */ public function get_description() { return "The quiz attempt with id '$this->objectid' belonging to the quiz with course module id '$this->contextinstanceid' " . "for the user with id '$this->relateduserid' became overdue."; } /** * Returns localised general event name. * * @return string */ public static function get_name() { return get_string('eventquizattempttimelimitexceeded', 'mod_quiz'); } /** * Returns relevant URL. * * @return \moodle_url */ public function get_url() { return new \moodle_url('/mod/quiz/review.php', ['attempt' => $this->objectid]); } /** * Custom validation. * * @throws \coding_exception * @return void */ protected function validate_data() { parent::validate_data(); if (!isset($this->relateduserid)) { throw new \coding_exception('The \'relateduserid\' must be set.'); } if (!array_key_exists('submitterid', $this->other)) { throw new \coding_exception('The \'submitterid\' value must be set in other.'); } } public static function get_objectid_mapping() { return ['db' => 'quiz_attempts', 'restore' => 'quiz_attempt']; } public static function get_other_mapping() { $othermapped = []; $othermapped['submitterid'] = ['db' => 'user', 'restore' => 'user']; $othermapped['quizid'] = ['db' => 'quiz', 'restore' => 'quiz']; return $othermapped; } } PK9B\(G  $classes/event/page_break_created.phpnu[. /** * The mod_quiz page break created event. * * @package mod_quiz * @copyright 2021 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace mod_quiz\event; /** * The mod_quiz page break created event class. * * @property-read array $other { * Extra information about event. * * - int quizid: the id of the quiz. * - int slotnumber: the slot number which we will add the page break before. * } * * @package mod_quiz * @copyright 2021 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class page_break_created extends \core\event\base { protected function init() { $this->data['objecttable'] = 'quiz_slots'; $this->data['crud'] = 'c'; $this->data['edulevel'] = self::LEVEL_TEACHING; } public static function get_name() { return get_string('eventpagebreakcreated', 'mod_quiz'); } public function get_description() { return "The user with id '$this->userid' created the page break before the slot with id '{$this->objectid}' " . "and slot number '{$this->other['slotnumber']}' " . "belonging to the quiz with course module id '$this->contextinstanceid'."; } public function get_url() { return new \moodle_url('/mod/quiz/edit.php', [ 'cmid' => $this->contextinstanceid ]); } protected function validate_data() { parent::validate_data(); if (!isset($this->objectid)) { throw new \coding_exception('The \'objectid\' value must be set.'); } if (!isset($this->contextinstanceid)) { throw new \coding_exception('The \'contextinstanceid\' value must be set.'); } if (!isset($this->other['quizid'])) { throw new \coding_exception('The \'quizid\' value must be set in other.'); } if (!isset($this->other['slotnumber'])) { throw new \coding_exception('The \'slotnumber\' value must be set in other.'); } } public static function get_objectid_mapping() { return ['db' => 'quiz_slots', 'restore' => 'quiz_question_instance']; } public static function get_other_mapping() { $othermapped = []; $othermapped['quizid'] = ['db' => 'quiz', 'restore' => 'quiz']; return $othermapped; } } PK9B\AT. . )classes/event/quiz_grade_item_created.phpnu[. namespace mod_quiz\event; /** * Event to record a quiz grade item being created. * * @property-read array $other { * } * * @package mod_quiz * @copyright 2023 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class quiz_grade_item_created extends \core\event\base { protected function init() { $this->data['objecttable'] = 'quiz_grade_items'; $this->data['crud'] = 'c'; $this->data['edulevel'] = self::LEVEL_TEACHING; } public static function get_name() { return get_string('eventquizgradeitemcreated', 'mod_quiz'); } public function get_description() { return "The user with id '$this->userid' created quiz grade item with id '$this->objectid' " . "for the quiz with course module id '$this->contextinstanceid'."; } public function get_url() { return new \moodle_url('/mod/quiz/editgrading.php', [ 'cmid' => $this->contextinstanceid, ]); } protected function validate_data() { parent::validate_data(); if (!isset($this->objectid)) { throw new \coding_exception('The \'objectid\' value must be set.'); } if (!isset($this->contextinstanceid)) { throw new \coding_exception('The \'contextinstanceid\' value must be set.'); } } public static function get_objectid_mapping() { return ['db' => 'quiz_grade_items', 'restore' => 'quiz_grade_items']; } public static function get_other_mapping() { return [ 'quizid' => ['db' => 'quiz', 'restore' => 'quiz'], ]; } } PK9B\tH5 #classes/event/attempt_abandoned.phpnu[. /** * The mod_quiz attempt abandoned event. * * @package mod_quiz * @copyright 2013 Adrian Greeve * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace mod_quiz\event; defined('MOODLE_INTERNAL') || die(); /** * The mod_quiz attempt abandoned event class. * * @property-read array $other { * Extra information about event. * * - int submitterid: id of submitter (null when trigged by CLI script). * - int quizid: (optional) id of the quiz. * } * * @package mod_quiz * @since Moodle 2.6 * @copyright 2013 Adrian Greeve * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class attempt_abandoned extends \core\event\base { /** * Init method. */ protected function init() { $this->data['objecttable'] = 'quiz_attempts'; $this->data['crud'] = 'u'; $this->data['edulevel'] = self::LEVEL_PARTICIPATING; } /** * Returns description of what happened. * * @return string */ public function get_description() { return "The user with id '$this->relateduserid' has had their attempt with id '$this->objectid' marked as abandoned " . "for the quiz with course module id '$this->contextinstanceid'."; } /** * Returns localised general event name. * * @return string */ public static function get_name() { return get_string('eventquizattemptabandoned', 'mod_quiz'); } /** * Returns relevant URL. * * @return \moodle_url */ public function get_url() { return new \moodle_url('/mod/quiz/review.php', ['attempt' => $this->objectid]); } /** * Custom validation. * * @throws \coding_exception * @return void */ protected function validate_data() { parent::validate_data(); if (!isset($this->relateduserid)) { throw new \coding_exception('The \'relateduserid\' must be set.'); } if (!array_key_exists('submitterid', $this->other)) { throw new \coding_exception('The \'submitterid\' value must be set in other.'); } } public static function get_objectid_mapping() { return ['db' => 'quiz_attempts', 'restore' => 'quiz_attempt']; } public static function get_other_mapping() { $othermapped = []; $othermapped['submitterid'] = ['db' => 'user', 'restore' => 'user']; $othermapped['quizid'] = ['db' => 'quiz', 'restore' => 'quiz']; return $othermapped; } } PK9B\ G G 'classes/event/user_override_updated.phpnu[. /** * The mod_quiz user override updated event. * * @package mod_quiz * @copyright 2014 Mark Nelson * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace mod_quiz\event; defined('MOODLE_INTERNAL') || die(); /** * The mod_quiz user override updated event class. * * @property-read array $other { * Extra information about event. * * - int quizid: the id of the quiz. * } * * @package mod_quiz * @since Moodle 2.7 * @copyright 2014 Mark Nelson * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class user_override_updated extends \core\event\base { /** * Init method. */ protected function init() { $this->data['objecttable'] = 'quiz_overrides'; $this->data['crud'] = 'u'; $this->data['edulevel'] = self::LEVEL_TEACHING; } /** * Returns localised general event name. * * @return string */ public static function get_name() { return get_string('eventoverrideupdated', 'mod_quiz'); } /** * Returns description of what happened. * * @return string */ public function get_description() { return "The user with id '$this->userid' updated the override with id '$this->objectid' for the quiz with " . "course module id '$this->contextinstanceid' for the user with id '{$this->relateduserid}'."; } /** * Returns relevant URL. * * @return \moodle_url */ public function get_url() { return new \moodle_url('/mod/quiz/overrideedit.php', ['id' => $this->objectid]); } /** * Custom validation. * * @throws \coding_exception * @return void */ protected function validate_data() { parent::validate_data(); if (!isset($this->relateduserid)) { throw new \coding_exception('The \'relateduserid\' must be set.'); } if (!isset($this->other['quizid'])) { throw new \coding_exception('The \'quizid\' value must be set in other.'); } } public static function get_objectid_mapping() { return ['db' => 'quiz_overrides', 'restore' => 'quiz_override']; } public static function get_other_mapping() { $othermapped = []; $othermapped['quizid'] = ['db' => 'quiz', 'restore' => 'quiz']; return $othermapped; } } PK9B\I$G G classes/event/attempt_graded.phpnu[. /** * The mod_quiz attempt submitted event. * * @package mod_quiz * @copyright 2024 Catalyst IT Europe Ltd. * @author Mark Johnson * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace mod_quiz\event; /** * The mod_quiz attempt graded event class. * * @package mod_quiz * @since Moodle 4.5 * @copyright 2024 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class attempt_graded extends \core\event\base { /** * Init method. */ protected function init(): void { $this->data['objecttable'] = 'quiz_attempts'; $this->data['crud'] = 'u'; $this->data['edulevel'] = self::LEVEL_PARTICIPATING; } /** * Returns description of what happened. * * @return string */ public function get_description(): string { return "The attempt with id {$this->objectid} for for quiz with course module id {$this->contextinstanceid} by user with " . "id {$this->relateduserid} had automatic grading performed."; } /** * Returns localised general event name. * * @return string */ public static function get_name(): string { return get_string('eventquizattemptgraded', 'mod_quiz'); } /** * Returns relevant URL. * * @return \moodle_url */ public function get_url(): \moodle_url { return new \moodle_url('/mod/quiz/review.php', ['attempt' => $this->objectid]); } /** * Custom validation. * * @throws \coding_exception * @return void */ protected function validate_data(): void { parent::validate_data(); if (!isset($this->relateduserid)) { throw new \coding_exception('The \'relateduserid\' must be set.'); } if (!array_key_exists('submitterid', $this->other)) { throw new \coding_exception('The \'submitterid\' value must be set in other.'); } } #[\Override] public static function get_objectid_mapping(): array { return ['db' => 'quiz_attempts', 'restore' => 'quiz_attempt']; } #[\Override] public static function get_other_mapping(): array { $othermapped = []; $othermapped['submitterid'] = ['db' => 'user', 'restore' => 'user']; $othermapped['quizid'] = ['db' => 'quiz', 'restore' => 'quiz']; return $othermapped; } } PK9B\GC{t "classes/event/edit_page_viewed.phpnu[. /** * The mod_quiz edit page viewed event. * * @package mod_quiz * @copyright 2014 Mark Nelson * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace mod_quiz\event; defined('MOODLE_INTERNAL') || die(); /** * The mod_quiz edit page viewed event class. * * @property-read array $other { * Extra information about event. * * - int quizid: the id of the quiz. * } * * @package mod_quiz * @since Moodle 2.7 * @copyright 2014 Mark Nelson * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class edit_page_viewed extends \core\event\base { /** * Init method. */ protected function init() { $this->data['crud'] = 'r'; $this->data['edulevel'] = self::LEVEL_TEACHING; } /** * Returns localised general event name. * * @return string */ public static function get_name() { return get_string('eventeditpageviewed', 'mod_quiz'); } /** * Returns description of what happened. * * @return string */ public function get_description() { return "The user with id '$this->userid' viewed the edit page for the quiz with " . "course module id '$this->contextinstanceid'."; } /** * Returns relevant URL. * * @return \moodle_url */ public function get_url() { return new \moodle_url('/mod/quiz/edit.php', ['cmid' => $this->contextinstanceid]); } /** * Custom validation. * * @throws \coding_exception * @return void */ protected function validate_data() { parent::validate_data(); if (!isset($this->other['quizid'])) { throw new \coding_exception('The \'quizid\' value must be set in other.'); } } public static function get_other_mapping() { $othermapped = []; $othermapped['quizid'] = ['db' => 'quiz', 'restore' => 'quiz']; return $othermapped; } } PK9B\ 0 0 #classes/event/attempt_submitted.phpnu[. /** * The mod_quiz attempt submitted event. * * @package mod_quiz * @copyright 2013 Adrian Greeve * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace mod_quiz\event; defined('MOODLE_INTERNAL') || die(); /** * The mod_quiz attempt submitted event class. * * @property-read array $other { * Extra information about event. * * - int submitterid: id of submitter (null when trigged by CLI script). * - int quizid: (optional) the id of the quiz. * - bool studentisonline: is the student currently interacting with Moodle? * } * * @package mod_quiz * @since Moodle 2.6 * @copyright 2013 Adrian Greeve * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class attempt_submitted extends \core\event\base { /** * Init method. */ protected function init() { $this->data['objecttable'] = 'quiz_attempts'; $this->data['crud'] = 'u'; $this->data['edulevel'] = self::LEVEL_PARTICIPATING; } /** * Returns description of what happened. * * @return string */ public function get_description() { return "The user with id '$this->relateduserid' has submitted the attempt with id '$this->objectid' for the " . "quiz with course module id '$this->contextinstanceid'."; } /** * Returns localised general event name. * * @return string */ public static function get_name() { return get_string('eventquizattemptsubmitted', 'mod_quiz'); } /** * Returns relevant URL. * * @return \moodle_url */ public function get_url() { return new \moodle_url('/mod/quiz/review.php', ['attempt' => $this->objectid]); } /** * Custom validation. * * @throws \coding_exception * @return void */ protected function validate_data() { parent::validate_data(); if (!isset($this->relateduserid)) { throw new \coding_exception('The \'relateduserid\' must be set.'); } if (!array_key_exists('submitterid', $this->other)) { throw new \coding_exception('The \'submitterid\' value must be set in other.'); } } public static function get_objectid_mapping() { return ['db' => 'quiz_attempts', 'restore' => 'quiz_attempt']; } public static function get_other_mapping() { $othermapped = []; $othermapped['submitterid'] = ['db' => 'user', 'restore' => 'user']; $othermapped['quizid'] = ['db' => 'quiz', 'restore' => 'quiz']; return $othermapped; } } PK9B\% classes/event/slot_created.phpnu[. /** * The mod_quiz slots created event. * * @package mod_quiz * @copyright 2021 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace mod_quiz\event; /** * The mod_quiz slot created event class. * * @property-read array $other { * Extra information about event. * * - int quizid: the id of the quiz. * - int slotnumber: the slot number in quiz. * - int page: page number. * } * * @package mod_quiz * @copyright 2021 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class slot_created extends \core\event\base { protected function init() { $this->data['objecttable'] = 'quiz_slots'; $this->data['crud'] = 'c'; $this->data['edulevel'] = self::LEVEL_TEACHING; } public static function get_name() { return get_string('eventslotcreated', 'mod_quiz'); } public function get_description() { return "The user with id '$this->userid' created a new slot with id '{$this->objectid}' " . "and slot number '{$this->other['slotnumber']}' " . "on page '{$this->other['page']}' " . "of the quiz with course module id '$this->contextinstanceid'."; } public function get_url() { return new \moodle_url('/mod/quiz/edit.php', [ 'cmid' => $this->contextinstanceid ]); } protected function validate_data() { parent::validate_data(); if (!isset($this->objectid)) { throw new \coding_exception('The \'objectid\' value must be set.'); } if (!isset($this->contextinstanceid)) { throw new \coding_exception('The \'contextinstanceid\' value must be set.'); } if (!isset($this->other['quizid'])) { throw new \coding_exception('The \'quizid\' value must be set in other.'); } if (!isset($this->other['slotnumber'])) { throw new \coding_exception('The \'slotnumber\' value must be set in other.'); } if (!isset($this->other['page'])) { throw new \coding_exception('The \'page\' value must be set in other.'); } } public static function get_objectid_mapping() { return ['db' => 'quiz_slots', 'restore' => 'quiz_question_instance']; } public static function get_other_mapping() { $othermapped = []; $othermapped['quizid'] = ['db' => 'quiz', 'restore' => 'quiz']; return $othermapped; } } PK9B\ko%%#classes/event/attempt_autosaved.phpnu[. /** * The mod_quiz attempt auto-saved event. * * @package mod_quiz * @copyright 2021 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace mod_quiz\event; /** * The mod_quiz attempt auto-saved event class. * * @property-read array $other { * Extra information about event. * * - int quizid: the id of the quiz. * - int page: the page number of attempt. * } * * @package mod_quiz * @copyright 2021 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class attempt_autosaved extends \core\event\base { /** * Init method. */ protected function init() { $this->data['objecttable'] = 'quiz_attempts'; $this->data['crud'] = 'u'; $this->data['edulevel'] = self::LEVEL_PARTICIPATING; } /** * Returns localised general event name. * * @return string */ public static function get_name() { return get_string('eventattemptautosaved', 'mod_quiz'); } /** * Returns description of what happened. * * @return string */ public function get_description() { $pagenumber = $this->other['page'] + 1; return "The user with id '$this->userid' is working on page " . "'{$pagenumber}' of the attempt " . "with id '$this->objectid' for the quiz with course module id '$this->contextinstanceid', " . "and their latest responses have been saved automatically."; } /** * Returns relevant URL. * * @return \moodle_url */ public function get_url() { return new \moodle_url('/mod/quiz/review.php', [ 'attempt' => $this->objectid, 'page' => $this->other['page'] ]); } /** * Custom validation. * * @throws \coding_exception * @return void */ protected function validate_data() { parent::validate_data(); if (!isset($this->relateduserid)) { throw new \coding_exception('The \'relateduserid\' must be set.'); } if (!isset($this->other['quizid'])) { throw new \coding_exception('The \'quizid\' value must be set in other.'); } if (!isset($this->other['page'])) { throw new \coding_exception('The \'page\' value must be set in other.'); } } /** * This is used when restoring course logs where it is required that we * map the information in 'other' to it's new value in the new course. * * @return array List of mapping of other ids. */ public static function get_objectid_mapping() { return ['db' => 'quiz_attempts', 'restore' => 'quiz_attempt']; } /** * This is used when restoring course logs where it is required that we * map the information in 'other' to it's new value in the new course. * * @return array List of mapping of other ids. */ public static function get_other_mapping() { $othermapped = []; $othermapped['quizid'] = ['db' => 'quiz', 'restore' => 'quiz']; return $othermapped; } } PK9B\:f”: : #classes/event/slot_mark_updated.phpnu[. namespace mod_quiz\event; /** * The mark a slot is graded out of has changed. * * @property-read array $other { * Extra information about event. * * - int quizid: the id of the quiz. * - int previousmaxmark: the previous max mark value. * - int newmaxmark: the new max mark value. * } * * @package mod_quiz * @copyright 2021 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class slot_mark_updated extends \core\event\base { protected function init() { $this->data['objecttable'] = 'quiz_slots'; $this->data['crud'] = 'u'; $this->data['edulevel'] = self::LEVEL_TEACHING; } public static function get_name() { return get_string('eventslotmarkupdated', 'mod_quiz'); } public function get_description() { return "The user with id '$this->userid' updated the slot with id '{$this->objectid}' " . "belonging to the quiz with course module id '$this->contextinstanceid'. " . "Its max mark was changed from '{$this->other['previousmaxmark']}' to '{$this->other['newmaxmark']}'."; } public function get_url() { return new \moodle_url('/mod/quiz/edit.php', [ 'cmid' => $this->contextinstanceid ]); } protected function validate_data() { parent::validate_data(); if (!isset($this->objectid)) { throw new \coding_exception('The \'objectid\' value must be set.'); } if (!isset($this->contextinstanceid)) { throw new \coding_exception('The \'contextinstanceid\' value must be set.'); } if (!isset($this->other['quizid'])) { throw new \coding_exception('The \'quizid\' value must be set in other.'); } if (!isset($this->other['previousmaxmark'])) { throw new \coding_exception('The \'previousmaxmark\' value must be set in other.'); } if (!isset($this->other['newmaxmark'])) { throw new \coding_exception('The \'newmaxmark\' value must be set in other.'); } } public static function get_objectid_mapping() { return ['db' => 'quiz_slots', 'restore' => 'quiz_question_instance']; } public static function get_other_mapping() { $othermapped = []; $othermapped['quizid'] = ['db' => 'quiz', 'restore' => 'quiz']; return $othermapped; } } PK9B\m< < 2classes/event/attempt_manual_grading_completed.phpnu[. namespace mod_quiz\event; /** * The mod_quiz attempt manual grading complete event. * * @package mod_quiz * @copyright 2021 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class attempt_manual_grading_completed extends \core\event\base { protected function init() { $this->data['objecttable'] = 'quiz_attempts'; $this->data['crud'] = 'u'; $this->data['edulevel'] = self::LEVEL_TEACHING; } public function get_description() { return "The attempt with id '$this->objectid' for the user with id '$this->relateduserid' " . "for the quiz with course module id '$this->contextinstanceid' is now fully graded. Sending notification."; } public static function get_name() { return get_string('eventattemptmanualgradingcomplete', 'mod_quiz'); } public function get_url() { return new \moodle_url('/mod/quiz/review.php', ['attempt' => $this->objectid]); } protected function validate_data() { parent::validate_data(); if (!isset($this->relateduserid)) { throw new \coding_exception('The \'relateduserid\' must be set.'); } if (!isset($this->other['quizid'])) { throw new \coding_exception('The \'quizid\' value must be set in other.'); } } public static function get_objectid_mapping() { return ['db' => 'quiz_attempts', 'restore' => 'quiz_attempt']; } public static function get_other_mapping() { $othermapped = []; $othermapped['quizid'] = ['db' => 'quiz', 'restore' => 'quiz']; return $othermapped; } } PK9B\ (classes/event/group_override_deleted.phpnu[. /** * The mod_quiz group override deleted event. * * @package mod_quiz * @copyright 2014 Mark Nelson * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace mod_quiz\event; defined('MOODLE_INTERNAL') || die(); /** * The mod_quiz group override deleted event class. * * @property-read array $other { * Extra information about event. * * - int quizid: the id of the quiz. * - int groupid: the id of the group. * } * * @package mod_quiz * @since Moodle 2.7 * @copyright 2014 Mark Nelson * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class group_override_deleted extends \core\event\base { /** * Init method. */ protected function init() { $this->data['objecttable'] = 'quiz_overrides'; $this->data['crud'] = 'd'; $this->data['edulevel'] = self::LEVEL_TEACHING; } /** * Returns localised general event name. * * @return string */ public static function get_name() { return get_string('eventoverridedeleted', 'mod_quiz'); } /** * Returns description of what happened. * * @return string */ public function get_description() { return "The user with id '$this->userid' deleted the override with id '$this->objectid' for the quiz with " . "course module id '$this->contextinstanceid' for the group with id '{$this->other['groupid']}'."; } /** * Returns relevant URL. * * @return \moodle_url */ public function get_url() { return new \moodle_url('/mod/quiz/overrides.php', ['cmid' => $this->contextinstanceid]); } /** * Custom validation. * * @throws \coding_exception * @return void */ protected function validate_data() { parent::validate_data(); if (!isset($this->other['quizid'])) { throw new \coding_exception('The \'quizid\' value must be set in other.'); } if (!isset($this->other['groupid'])) { throw new \coding_exception('The \'groupid\' value must be set in other.'); } } public static function get_objectid_mapping() { return ['db' => 'quiz_overrides', 'restore' => 'quiz_override']; } public static function get_other_mapping() { $othermapped = []; $othermapped['quizid'] = ['db' => 'quiz', 'restore' => 'quiz']; $othermapped['groupid'] = ['db' => 'groups', 'restore' => 'group']; return $othermapped; } } PK9B\B | 'classes/event/section_break_deleted.phpnu[. /** * The mod_quiz section break deleted event. * * @package mod_quiz * @copyright 2021 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace mod_quiz\event; /** * The mod_quiz section break deleted event class. * * @property-read array $other { * Extra information about event. * * - int quizid: the id of the quiz. * - int firstslotid: id of the slot which we will remove the section break before. * - int firstslotnumber: slot number of the slot which we will remove the section break before. * } * * @package mod_quiz * @copyright 2021 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class section_break_deleted extends \core\event\base { protected function init() { $this->data['objecttable'] = 'quiz_sections'; $this->data['crud'] = 'd'; $this->data['edulevel'] = self::LEVEL_TEACHING; } public static function get_name() { return get_string('eventsectionbreakdeleted', 'mod_quiz'); } public function get_description() { return "The user with id '$this->userid' deleted the section break with id '{$this->objectid}' " . "before the slot with id '{$this->other['firstslotid']}' and slot number '{$this->other['firstslotnumber']}' " . "belonging to the quiz with course module id '$this->contextinstanceid'."; } public function get_url() { return new \moodle_url('/mod/quiz/edit.php', [ 'cmid' => $this->contextinstanceid ]); } protected function validate_data() { parent::validate_data(); if (!isset($this->objectid)) { throw new \coding_exception('The \'objectid\' value must be set.'); } if (!isset($this->contextinstanceid)) { throw new \coding_exception('The \'contextinstanceid\' value must be set.'); } if (!isset($this->other['quizid'])) { throw new \coding_exception('The \'quizid\' value must be set in other.'); } if (!isset($this->other['firstslotid'])) { throw new \coding_exception('The \'firstslotid\' value must be set in other.'); } if (!isset($this->other['firstslotnumber'])) { throw new \coding_exception('The \'firstslotnumber\' value must be set in other.'); } } public static function get_objectid_mapping() { return ['db' => 'quiz_sections', 'restore' => 'quiz_section']; } public static function get_other_mapping() { $othermapped = []; $othermapped['quizid'] = ['db' => 'quiz', 'restore' => 'quiz']; $othermapped['firstslotid'] = ['db' => 'quiz_slots', 'restore' => 'quiz_question_instance']; return $othermapped; } } PK9B\3D "classes/event/attempt_reopened.phpnu[. namespace mod_quiz\event; use coding_exception; use core\event\base; use moodle_url; /** * Event fired when a quiz attempt is reopened. * * @property-read array $other { * Extra information about event. * * - int submitterid: id of submitter (null when triggered by CLI script). * - int quizid: (optional) id of the quiz. * } * * @package mod_quiz * @since Moodle 4.2 * @copyright 2023 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class attempt_reopened extends base { protected function init() { $this->data['objecttable'] = 'quiz_attempts'; $this->data['crud'] = 'u'; $this->data['edulevel'] = self::LEVEL_TEACHING; } public function get_description(): string { return "The user with id '$this->relateduserid' has had their attempt with id '$this->objectid'" . "for the quiz with course module id '$this->contextinstanceid' re-opened by the user with id '$this->userid'."; } public static function get_name(): string { return get_string('eventquizattemptreopened', 'mod_quiz'); } public function get_url(): moodle_url { return new moodle_url('/mod/quiz/review.php', ['attempt' => $this->objectid]); } protected function validate_data(): void { parent::validate_data(); if (!isset($this->relateduserid)) { throw new coding_exception('The \'relateduserid\' must be set.'); } if (!array_key_exists('submitterid', $this->other)) { throw new coding_exception('The \'submitterid\' value must be set in other.'); } } public static function get_objectid_mapping(): array { return ['db' => 'quiz_attempts', 'restore' => 'quiz_attempt']; } public static function get_other_mapping(): array { return [ 'submitterid' => ['db' => 'user', 'restore' => 'user'], 'quizid' => ['db' => 'quiz', 'restore' => 'quiz'], ]; } } PK9B\+ )classes/event/slot_grade_item_updated.phpnu[. namespace mod_quiz\event; use core\event\base; /** * The quiz sub-grade that this slot contributes to has changed. * * @property-read array $other { * Extra information about event. * * - int quizid: the id of the quiz. * - int previousgradeitem: the previous max mark value. * - int newgradeitem: the new max mark value. * } * * @package mod_quiz * @copyright 2023 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class slot_grade_item_updated extends base { protected function init() { $this->data['objecttable'] = 'quiz_slots'; $this->data['crud'] = 'u'; $this->data['edulevel'] = self::LEVEL_TEACHING; } public static function get_name() { return get_string('eventslotgradeitemupdated', 'mod_quiz'); } public function get_description() { return "The user with id '$this->userid' updated the slot with id '{$this->objectid}' " . "belonging to the quiz with course module id '$this->contextinstanceid'. " . "The grade item this slot contributes to was changed from '{$this->other['previousgradeitem']}' " . "to '{$this->other['newgradeitem']}'."; } public function get_url() { return new \moodle_url('/mod/quiz/editgrading.php', [ 'cmid' => $this->contextinstanceid, ]); } protected function validate_data() { parent::validate_data(); if (!isset($this->objectid)) { throw new \coding_exception('The \'objectid\' value must be set.'); } if (!isset($this->contextinstanceid)) { throw new \coding_exception('The \'contextinstanceid\' value must be set.'); } if (!isset($this->other['quizid'])) { throw new \coding_exception('The \'quizid\' value must be set in other.'); } if (!array_key_exists('previousgradeitem', $this->other)) { throw new \coding_exception('The \'previousgradeitem\' value must be set in other.'); } if (!array_key_exists('newgradeitem', $this->other)) { throw new \coding_exception('The \'newgradeitem\' value must be set in other.'); } } public static function get_objectid_mapping() { return ['db' => 'quiz_slots', 'restore' => 'quiz_question_instance']; } public static function get_other_mapping() { return [ 'quizid' => ['db' => 'quiz', 'restore' => 'quiz'], ]; } } PK9B\h !classes/event/attempt_started.phpnu[. /** * The mod_quiz attempt started event. * * @package mod_quiz * @copyright 2013 Adrian Greeve * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace mod_quiz\event; defined('MOODLE_INTERNAL') || die(); /** * The mod_quiz attempt started event class. * * @property-read array $other { * Extra information about event. * * - int quizid: (optional) the id of the quiz. * } * * @package mod_quiz * @since Moodle 2.6 * @copyright 2013 Adrian Greeve * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class attempt_started extends \core\event\base { /** * Init method. */ protected function init() { $this->data['objecttable'] = 'quiz_attempts'; $this->data['crud'] = 'c'; $this->data['edulevel'] = self::LEVEL_PARTICIPATING; } /** * Returns description of what happened. * * @return string */ public function get_description() { return "The user with id '$this->relateduserid' has started the attempt with id '$this->objectid' for the " . "quiz with course module id '$this->contextinstanceid'."; } /** * Returns localised general event name. * * @return string */ public static function get_name() { return get_string('eventquizattemptstarted', 'mod_quiz'); } /** * Returns relevant URL. * * @return \moodle_url */ public function get_url() { return new \moodle_url('/mod/quiz/review.php', ['attempt' => $this->objectid]); } /** * Custom validation. * * @throws \coding_exception * @return void */ protected function validate_data() { parent::validate_data(); if (!isset($this->relateduserid)) { throw new \coding_exception('The \'relateduserid\' must be set.'); } } public static function get_objectid_mapping() { return ['db' => 'quiz_attempts', 'restore' => 'quiz_attempt']; } public static function get_other_mapping() { $othermapped = []; $othermapped['quizid'] = ['db' => 'quiz', 'restore' => 'quiz']; return $othermapped; } } PK9B\2" " $classes/event/page_break_deleted.phpnu[. /** * The mod_quiz page break deleted event. * * @package mod_quiz * @copyright 2021 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace mod_quiz\event; /** * The mod_quiz page break deleted event class. * * @property-read array $other { * Extra information about event. * * - int quizid: the id of the quiz. * - int slotnumber: the slot number which we will remove the page break before. * } * * @package mod_quiz * @copyright 2021 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class page_break_deleted extends \core\event\base { protected function init() { $this->data['objecttable'] = 'quiz_slots'; $this->data['crud'] = 'd'; $this->data['edulevel'] = self::LEVEL_TEACHING; } public static function get_name() { return get_string('eventpagebreakdeleted', 'mod_quiz'); } public function get_description() { return "The user with id '$this->userid' deleted the page break before the slot with id '{$this->objectid}' " . "and slot number '{$this->other['slotnumber']}' " . "belonging to the quiz with course module id '$this->contextinstanceid'."; } public function get_url() { return new \moodle_url('/mod/quiz/edit.php', [ 'cmid' => $this->contextinstanceid ]); } protected function validate_data() { parent::validate_data(); if (!isset($this->objectid)) { throw new \coding_exception('The \'objectid\' value must be set.'); } if (!isset($this->contextinstanceid)) { throw new \coding_exception('The \'contextinstanceid\' value must be set.'); } if (!isset($this->other['quizid'])) { throw new \coding_exception('The \'quizid\' value must be set in other.'); } if (!isset($this->other['slotnumber'])) { throw new \coding_exception('The \'slotnumber\' value must be set in other.'); } } public static function get_objectid_mapping() { return ['db' => 'quiz_slots', 'restore' => 'quiz_question_instance']; } public static function get_other_mapping() { $othermapped = []; $othermapped['quizid'] = ['db' => 'quiz', 'restore' => 'quiz']; return $othermapped; } } PK9B\ hi  "classes/event/attempt_regraded.phpnu[. /** * The mod_quiz attempt regraded event. * * @package mod_quiz * @copyright 2020 Russell Boyatt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace mod_quiz\event; defined('MOODLE_INTERNAL') || die(); /** * The mod_quiz attempt regraded event class. * * @property-read array $other { * Extra information about event. * * - int quizid: the id of the quiz. * } * * @package mod_quiz * @copyright 2020 Russell Boyatt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class attempt_regraded extends \core\event\base { /** * Init method. */ protected function init() { $this->data['objecttable'] = 'quiz_attempts'; $this->data['crud'] = 'u'; $this->data['edulevel'] = self::LEVEL_TEACHING; } /** * Returns localised general event name. * * @return string */ public static function get_name() { return get_string('eventquizattemptregraded', 'mod_quiz'); } /** * Returns description of what happened. * * @return string */ public function get_description() { return "The user with id '$this->userid' regraded quiz attempt with id '$this->objectid' by user " . "with id '$this->relateduserid' for the quiz with course module id '$this->contextinstanceid'."; } /** * Returns relevant URL. * * @return \moodle_url */ public function get_url() { return new \moodle_url('/mod/quiz/review.php', ['attempt' => $this->objectid]); } /** * Custom validation. * * @throws \coding_exception * @return void */ protected function validate_data() { parent::validate_data(); if (!isset($this->relateduserid)) { throw new \coding_exception('The \'relateduserid\' must be set.'); } if (!isset($this->userid)) { throw new \coding_exception('The \'userid\' must be set.'); } if (!isset($this->other['quizid'])) { throw new \coding_exception('The \'quizid\' value must be set in other.'); } } /** * Get mapping to objects * * @return array Array of mappings */ public static function get_objectid_mapping() { return ['db' => 'quiz_attempts', 'restore' => 'quiz_attempt']; } /** * Retrieve other mapping detail for the event. * * @return array Array of array mappings */ public static function get_other_mapping() { $othermapped = []; $othermapped['quizid'] = ['db' => 'quiz', 'restore' => 'quiz']; return $othermapped; } } PK9B\/ (classes/event/group_override_updated.phpnu[. /** * The mod_quiz group override updated event. * * @package mod_quiz * @copyright 2014 Mark Nelson * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace mod_quiz\event; defined('MOODLE_INTERNAL') || die(); /** * The mod_quiz group override updated event class. * * @property-read array $other { * Extra information about event. * * - int quizid: the id of the quiz. * - int groupid: the id of the group. * } * * @package mod_quiz * @since Moodle 2.7 * @copyright 2014 Mark Nelson * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class group_override_updated extends \core\event\base { /** * Init method. */ protected function init() { $this->data['objecttable'] = 'quiz_overrides'; $this->data['crud'] = 'u'; $this->data['edulevel'] = self::LEVEL_TEACHING; } /** * Returns localised general event name. * * @return string */ public static function get_name() { return get_string('eventoverrideupdated', 'mod_quiz'); } /** * Returns description of what happened. * * @return string */ public function get_description() { return "The user with id '$this->userid' updated the override with id '$this->objectid' for the quiz with " . "course module id '$this->contextinstanceid' for the group with id '{$this->other['groupid']}'."; } /** * Returns relevant URL. * * @return \moodle_url */ public function get_url() { return new \moodle_url('/mod/quiz/overrideedit.php', ['id' => $this->objectid]); } /** * Custom validation. * * @throws \coding_exception * @return void */ protected function validate_data() { parent::validate_data(); if (!isset($this->other['quizid'])) { throw new \coding_exception('The \'quizid\' value must be set in other.'); } if (!isset($this->other['groupid'])) { throw new \coding_exception('The \'groupid\' value must be set in other.'); } } public static function get_objectid_mapping() { return ['db' => 'quiz_overrides', 'restore' => 'quiz_override']; } public static function get_other_mapping() { $othermapped = []; $othermapped['quizid'] = ['db' => 'quiz', 'restore' => 'quiz']; $othermapped['groupid'] = ['db' => 'groups', 'restore' => 'group']; return $othermapped; } } PK9B\| *classes/event/question_manually_graded.phpnu[. /** * The mod_quiz question manually graded event. * * @package core * @copyright 2014 Mark Nelson * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace mod_quiz\event; defined('MOODLE_INTERNAL') || die(); /** * The mod_quiz question manually graded event class. * * @property-read array $other { * Extra information about event. * * - int quizid: the id of the quiz. * - int attemptid: the id of the attempt. * - int slot: the question number in the attempt. * } * * @package core * @since Moodle 2.7 * @copyright 2014 Mark Nelson * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class question_manually_graded extends \core\event\base { /** * Init method. */ protected function init() { $this->data['objecttable'] = 'question'; $this->data['crud'] = 'u'; $this->data['edulevel'] = self::LEVEL_TEACHING; } /** * Returns localised general event name. * * @return string */ public static function get_name() { return get_string('eventquestionmanuallygraded', 'mod_quiz'); } /** * Returns description of what happened. * * @return string */ public function get_description() { return "The user with id '$this->userid' manually graded the question with id '$this->objectid' for the attempt " . "with id '{$this->other['attemptid']}' for the quiz with course module id '$this->contextinstanceid'."; } /** * Returns relevant URL. * * @return \moodle_url */ public function get_url() { return new \moodle_url('/mod/quiz/comment.php', ['attempt' => $this->other['attemptid'], 'slot' => $this->other['slot']]); } /** * Custom validation. * * @throws \coding_exception * @return void */ protected function validate_data() { parent::validate_data(); if (!isset($this->other['quizid'])) { throw new \coding_exception('The \'quizid\' value must be set in other.'); } if (!isset($this->other['attemptid'])) { throw new \coding_exception('The \'attemptid\' value must be set in other.'); } if (!isset($this->other['slot'])) { throw new \coding_exception('The \'slot\' value must be set in other.'); } } public static function get_objectid_mapping() { return ['db' => 'question', 'restore' => 'question']; } public static function get_other_mapping() { $othermapped = []; $othermapped['quizid'] = ['db' => 'quiz', 'restore' => 'quiz']; $othermapped['attemptid'] = ['db' => 'quiz_attempts', 'restore' => 'quiz_attempt']; return $othermapped; } } PK9B\ (classes/event/group_override_created.phpnu[. /** * The mod_quiz group override created event. * * @package mod_quiz * @copyright 2014 Mark Nelson * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace mod_quiz\event; defined('MOODLE_INTERNAL') || die(); /** * The mod_quiz group override created event class. * * @property-read array $other { * Extra information about event. * * - int quizid: the id of the quiz. * - int groupid: the id of the group. * } * * @package mod_quiz * @since Moodle 2.7 * @copyright 2014 Mark Nelson * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class group_override_created extends \core\event\base { /** * Init method. */ protected function init() { $this->data['objecttable'] = 'quiz_overrides'; $this->data['crud'] = 'c'; $this->data['edulevel'] = self::LEVEL_TEACHING; } /** * Returns localised general event name. * * @return string */ public static function get_name() { return get_string('eventoverridecreated', 'mod_quiz'); } /** * Returns description of what happened. * * @return string */ public function get_description() { return "The user with id '$this->userid' created the override with id '$this->objectid' for the quiz with " . "course module id '$this->contextinstanceid' for the group with id '{$this->other['groupid']}'."; } /** * Returns relevant URL. * * @return \moodle_url */ public function get_url() { return new \moodle_url('/mod/quiz/overrideedit.php', ['id' => $this->objectid]); } /** * Custom validation. * * @throws \coding_exception * @return void */ protected function validate_data() { parent::validate_data(); if (!isset($this->other['quizid'])) { throw new \coding_exception('The \'quizid\' value must be set in other.'); } if (!isset($this->other['groupid'])) { throw new \coding_exception('The \'groupid\' value must be set in other.'); } } public static function get_objectid_mapping() { return ['db' => 'quiz_overrides', 'restore' => 'quiz_override']; } public static function get_other_mapping() { $othermapped = []; $othermapped['quizid'] = ['db' => 'quiz', 'restore' => 'quiz']; $othermapped['groupid'] = ['db' => 'groups', 'restore' => 'group']; return $othermapped; } } PK9B\!TT&classes/event/course_module_viewed.phpnu[. /** * The mod_quiz course module viewed event. * * @package mod_quiz * @copyright 2014 Mark Nelson * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace mod_quiz\event; defined('MOODLE_INTERNAL') || die(); /** * The mod_quiz course module viewed event class. * * @package mod_quiz * @since Moodle 2.7 * @copyright 2014 Mark Nelson * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class course_module_viewed extends \core\event\course_module_viewed { /** * Init method. * * @return void */ protected function init() { $this->data['crud'] = 'r'; $this->data['edulevel'] = self::LEVEL_PARTICIPATING; $this->data['objecttable'] = 'quiz'; } public static function get_objectid_mapping() { return ['db' => 'quiz', 'restore' => 'quiz']; } } PK9B\[ classes/event/report_viewed.phpnu[. /** * The mod_quiz report viewed event. * * @package mod_quiz * @copyright 2014 Mark Nelson * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace mod_quiz\event; defined('MOODLE_INTERNAL') || die(); /** * The mod_quiz report viewed event class. * * @property-read array $other { * Extra information about event. * * - int quizid: the id of the quiz. * - string reportname: the name of the report. * } * * @package mod_quiz * @since Moodle 2.7 * @copyright 2014 Mark Nelson * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class report_viewed extends \core\event\base { /** * Init method. * * @return void */ protected function init() { $this->data['crud'] = 'r'; $this->data['edulevel'] = self::LEVEL_TEACHING; } /** * Return localised event name. * * @return string */ public static function get_name() { return get_string('eventreportviewed', 'mod_quiz'); } /** * Returns description of what happened. * * @return string */ public function get_description() { return "The user with id '$this->userid' viewed the report '" . s($this->other['reportname']) . "' for the quiz with " . "course module id '$this->contextinstanceid'."; } /** * Get URL related to the action. * * @return \moodle_url */ public function get_url() { return new \moodle_url('/mod/quiz/report.php', ['id' => $this->contextinstanceid, 'mode' => $this->other['reportname']]); } /** * Custom validation. * * @throws \coding_exception * @return void */ protected function validate_data() { parent::validate_data(); if (!isset($this->other['quizid'])) { throw new \coding_exception('The \'quizid\' value must be set in other.'); } if (!isset($this->other['reportname'])) { throw new \coding_exception('The \'reportname\' value must be set in other.'); } } public static function get_other_mapping() { $othermapped = []; $othermapped['quizid'] = ['db' => 'quiz', 'restore' => 'quiz']; return $othermapped; } } PK9B\ghF F 'classes/event/user_override_created.phpnu[. /** * The mod_quiz user override created event. * * @package mod_quiz * @copyright 2014 Mark Nelson * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace mod_quiz\event; defined('MOODLE_INTERNAL') || die(); /** * The mod_quiz user override created event class. * * @property-read array $other { * Extra information about event. * * - int quizid: the id of the quiz. * } * * @package mod_quiz * @since Moodle 2.7 * @copyright 2014 Mark Nelson * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class user_override_created extends \core\event\base { /** * Init method. */ protected function init() { $this->data['objecttable'] = 'quiz_overrides'; $this->data['crud'] = 'c'; $this->data['edulevel'] = self::LEVEL_TEACHING; } /** * Returns localised general event name. * * @return string */ public static function get_name() { return get_string('eventoverridecreated', 'mod_quiz'); } /** * Returns description of what happened. * * @return string */ public function get_description() { return "The user with id '$this->userid' created the override with id '$this->objectid' for the quiz with " . "course module id '$this->contextinstanceid' for the user with id '{$this->relateduserid}'."; } /** * Returns relevant URL. * * @return \moodle_url */ public function get_url() { return new \moodle_url('/mod/quiz/overrideedit.php', ['id' => $this->objectid]); } /** * Custom validation. * * @throws \coding_exception * @return void */ protected function validate_data() { parent::validate_data(); if (!isset($this->relateduserid)) { throw new \coding_exception('The \'relateduserid\' must be set.'); } if (!isset($this->other['quizid'])) { throw new \coding_exception('The \'quizid\' value must be set in other.'); } } public static function get_objectid_mapping() { return ['db' => 'quiz_overrides', 'restore' => 'quiz_override']; } public static function get_other_mapping() { $othermapped = []; $othermapped['quizid'] = ['db' => 'quiz', 'restore' => 'quiz']; return $othermapped; } } PK9B\,classes/event/slot_displaynumber_updated.phpnu[. namespace mod_quiz\event; /** * The mod_quiz slot display updated event class. * * @property-read array $other { * Extra information about event. * * - int quizid: the id of the quiz. * - string displaynumber: the slot's customised question number value. * } * * @package mod_quiz * @copyright 2022 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class slot_displaynumber_updated extends \core\event\base { /** * Initialise the quiz_slots table. */ protected function init(): void { $this->data['objecttable'] = 'quiz_slots'; $this->data['crud'] = 'u'; $this->data['edulevel'] = self::LEVEL_TEACHING; } /** * Return the name of the event. * * @return string */ public static function get_name(): string { return get_string('eventslotdisplayedquestionnumberupdated', 'mod_quiz'); } /** * Log describes which user customised the question number in a given slot and in which quiz. * * @return string */ public function get_description(): string { return "The user with id '$this->userid' updated the slot with id '{$this->objectid}' " . "belonging to the quiz with course module id '$this->contextinstanceid'. " . "Its customised question number value was set to '{$this->other['displaynumber']}'."; } /** * Return the url object of the quiz editing page. * * @return \moodle_url */ public function get_url(): \moodle_url { return new \moodle_url('/mod/quiz/edit.php', ['cmid' => $this->contextinstanceid]); } /** * validate the data being logged. */ protected function validate_data(): void { parent::validate_data(); if (!isset($this->objectid)) { throw new \coding_exception('The \'objectid\' value must be set.'); } if (!isset($this->contextinstanceid)) { throw new \coding_exception('The \'contextinstanceid\' value must be set.'); } if (!isset($this->other['quizid'])) { throw new \coding_exception('The \'quizid\' value must be set in other.'); } if (!isset($this->other['displaynumber'])) { throw new \coding_exception('The \'displaynumber\' value must be set in other.'); } } /** * Return the mapped array. * * @return string[] */ public static function get_objectid_mapping(): array { return ['db' => 'quiz_slots', 'restore' => 'quiz_question_instance']; } /** * Return the mapped array. * * @return array */ public static function get_other_mapping(): array { $othermapped = []; $othermapped['quizid'] = ['db' => 'quiz', 'restore' => 'quiz']; return $othermapped; } } PK9B\&. . )classes/event/quiz_grade_item_deleted.phpnu[. namespace mod_quiz\event; /** * Event to record a quiz grade item being deleted. * * @property-read array $other { * } * * @package mod_quiz * @copyright 2023 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class quiz_grade_item_deleted extends \core\event\base { protected function init() { $this->data['objecttable'] = 'quiz_grade_items'; $this->data['crud'] = 'd'; $this->data['edulevel'] = self::LEVEL_TEACHING; } public static function get_name() { return get_string('eventquizgradeitemdeleted', 'mod_quiz'); } public function get_description() { return "The user with id '$this->userid' deleted quiz grade item with id '$this->objectid' " . "for the quiz with course module id '$this->contextinstanceid'."; } public function get_url() { return new \moodle_url('/mod/quiz/editgrading.php', [ 'cmid' => $this->contextinstanceid, ]); } protected function validate_data() { parent::validate_data(); if (!isset($this->objectid)) { throw new \coding_exception('The \'objectid\' value must be set.'); } if (!isset($this->contextinstanceid)) { throw new \coding_exception('The \'contextinstanceid\' value must be set.'); } } public static function get_objectid_mapping() { return ['db' => 'quiz_grade_items', 'restore' => 'quiz_grade_items']; } public static function get_other_mapping() { return [ 'quizid' => ['db' => 'quiz', 'restore' => 'quiz'], ]; } } PK9B\p classes/event/attempt_viewed.phpnu[. /** * The mod_quiz attempt viewed event. * * @package mod_quiz * @copyright 2014 Mark Nelson * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace mod_quiz\event; defined('MOODLE_INTERNAL') || die(); /** * The mod_quiz attempt viewed event class. * * @property-read array $other { * Extra information about event. * * - int quizid: the id of the quiz. * - int page: the page number of attempt. * } * * @package mod_quiz * @since Moodle 2.7 * @copyright 2014 Mark Nelson * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class attempt_viewed extends \core\event\base { /** * Init method. */ protected function init() { $this->data['objecttable'] = 'quiz_attempts'; $this->data['crud'] = 'r'; $this->data['edulevel'] = self::LEVEL_PARTICIPATING; } /** * Returns localised general event name. * * @return string */ public static function get_name() { return get_string('eventattemptviewed', 'mod_quiz'); } /** * Returns description of what happened. * * @return string */ public function get_description() { $page = isset($this->other['page']) ? $this->other['page'] + 1 : ''; return "The user with id '$this->userid' has viewed page '$page' of the attempt with id " . "'$this->objectid' belonging to the user with id '$this->relateduserid' for the quiz " . "with course module id '$this->contextinstanceid'."; } /** * Returns relevant URL. * * @return \moodle_url */ public function get_url() { return new \moodle_url('/mod/quiz/review.php', [ 'attempt' => $this->objectid, 'page' => isset($this->other['page']) ? $this->other['page'] : 0 ]); } /** * Custom validation. * * @throws \coding_exception * @return void */ protected function validate_data() { parent::validate_data(); if (!isset($this->relateduserid)) { throw new \coding_exception('The \'relateduserid\' must be set.'); } if (!isset($this->other['quizid'])) { throw new \coding_exception('The \'quizid\' value must be set in other.'); } if (!isset($this->other['page'])) { throw new \coding_exception('The \'page\' value must be set in other.'); } } public static function get_objectid_mapping() { return ['db' => 'quiz_attempts', 'restore' => 'quiz_attempt']; } public static function get_other_mapping() { $othermapped = []; $othermapped['quizid'] = ['db' => 'quiz', 'restore' => 'quiz']; return $othermapped; } } PK9B\qbclasses/event/slot_moved.phpnu[. /** * The mod_quiz slots moved event. * * @package mod_quiz * @copyright 2021 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace mod_quiz\event; /** * The mod_quiz slot moved event class. * * @property-read array $other { * Extra information about event. * * - int quizid: the id of the quiz. * - int previousslotnumber: the previous slot number in quiz. * - int afterslotnumber: the new slot number in quiz. * - int page: the page of new slot position in quiz. * } * * @package mod_quiz * @copyright 2021 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class slot_moved extends \core\event\base { protected function init() { $this->data['objecttable'] = 'quiz_slots'; $this->data['crud'] = 'u'; $this->data['edulevel'] = self::LEVEL_TEACHING; } public static function get_name() { return get_string('eventslotmoved', 'mod_quiz'); } public function get_description() { if ($this->other['afterslotnumber'] == 0) { $newposition = 'before the first slot'; } else { $newposition = "after slot number '{$this->other['afterslotnumber']}'"; } return "The user with id '$this->userid' has moved the slot with id '{$this->objectid}' " . "and slot number '{$this->other['previousslotnumber']}' to the new position $newposition " . "on page '{$this->other['page']}' belonging to the quiz with course module id '$this->contextinstanceid'."; } public function get_url() { return new \moodle_url('/mod/quiz/edit.php', [ 'cmid' => $this->contextinstanceid ]); } protected function validate_data() { parent::validate_data(); if (!isset($this->objectid)) { throw new \coding_exception('The \'objectid\' value must be set.'); } if (!isset($this->contextinstanceid)) { throw new \coding_exception('The \'contextinstanceid\' value must be set.'); } if (!isset($this->other['quizid'])) { throw new \coding_exception('The \'quizid\' value must be set in other.'); } if (!isset($this->other['previousslotnumber'])) { throw new \coding_exception('The \'previousslotnumber\' value must be set in other.'); } if (!isset($this->other['afterslotnumber'])) { throw new \coding_exception('The \'afterslotnumber\' value must be set in other.'); } if (!isset($this->other['page'])) { throw new \coding_exception('The \'page\' value must be set in other.'); } } public static function get_objectid_mapping() { return ['db' => 'quiz_slots', 'restore' => 'quiz_question_instance']; } public static function get_other_mapping() { $othermapped = []; $othermapped['quizid'] = ['db' => 'quiz', 'restore' => 'quiz']; return $othermapped; } } PK9B\|Y Y )classes/event/attempt_preview_started.phpnu[. /** * The mod_quiz attempt preview started event. * * @package mod_quiz * @copyright 2014 Mark Nelson * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace mod_quiz\event; defined('MOODLE_INTERNAL') || die(); /** * The mod_quiz attempt preview started event class. * * @property-read array $other { * Extra information about event. * * - int quizid: the id of the quiz. * } * * @package mod_quiz * @since Moodle 2.7 * @copyright 2014 Mark Nelson * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class attempt_preview_started extends \core\event\base { /** * Init method. */ protected function init() { $this->data['objecttable'] = 'quiz_attempts'; $this->data['crud'] = 'c'; $this->data['edulevel'] = self::LEVEL_TEACHING; } /** * Returns localised general event name. * * @return string */ public static function get_name() { return get_string('eventattemptpreviewstarted', 'mod_quiz'); } /** * Returns description of what happened. * * @return string */ public function get_description() { return "The user with id '$this->relateduserid' has had their attempt with id '$this->objectid' previewed by " . "the user with id '$this->userid' for the quiz with course module id '$this->contextinstanceid'."; } /** * Returns relevant URL. * * @return \moodle_url */ public function get_url() { return new \moodle_url('/mod/quiz/view.php', ['id' => $this->contextinstanceid]); } /** * Custom validation. * * @throws \coding_exception * @return void */ protected function validate_data() { parent::validate_data(); if (!isset($this->relateduserid)) { throw new \coding_exception('The \'relateduserid\' must be set.'); } if (!isset($this->other['quizid'])) { throw new \coding_exception('The \'quizid\' value must be set in other.'); } } public static function get_objectid_mapping() { return ['db' => 'quiz_attempts', 'restore' => 'quiz_attempt']; } public static function get_other_mapping() { $othermapped = []; $othermapped['quizid'] = ['db' => 'quiz', 'restore' => 'quiz']; return $othermapped; } } PK9B\M1,N N 'classes/event/user_override_deleted.phpnu[. /** * The mod_quiz user override deleted event. * * @package mod_quiz * @copyright 2014 Mark Nelson * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace mod_quiz\event; defined('MOODLE_INTERNAL') || die(); /** * The mod_quiz user override deleted event class. * * @property-read array $other { * Extra information about event. * * - int quizid: the id of the quiz. * } * * @package mod_quiz * @since Moodle 2.7 * @copyright 2014 Mark Nelson * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class user_override_deleted extends \core\event\base { /** * Init method. */ protected function init() { $this->data['objecttable'] = 'quiz_overrides'; $this->data['crud'] = 'd'; $this->data['edulevel'] = self::LEVEL_TEACHING; } /** * Returns localised general event name. * * @return string */ public static function get_name() { return get_string('eventoverridedeleted', 'mod_quiz'); } /** * Returns description of what happened. * * @return string */ public function get_description() { return "The user with id '$this->userid' deleted the override with id '$this->objectid' for the quiz with " . "course module id '$this->contextinstanceid' for the user with id '{$this->relateduserid}'."; } /** * Returns relevant URL. * * @return \moodle_url */ public function get_url() { return new \moodle_url('/mod/quiz/overrides.php', ['cmid' => $this->contextinstanceid]); } /** * Custom validation. * * @throws \coding_exception * @return void */ protected function validate_data() { parent::validate_data(); if (!isset($this->relateduserid)) { throw new \coding_exception('The \'relateduserid\' must be set.'); } if (!isset($this->other['quizid'])) { throw new \coding_exception('The \'quizid\' value must be set in other.'); } } public static function get_objectid_mapping() { return ['db' => 'quiz_overrides', 'restore' => 'quiz_override']; } public static function get_other_mapping() { $othermapped = []; $othermapped['quizid'] = ['db' => 'quiz', 'restore' => 'quiz']; return $othermapped; } } PK9B\S. /** * The mod_quiz slots require previous updated event. * * @package mod_quiz * @copyright 2021 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace mod_quiz\event; /** * The mod_quiz slot require previous updated event class. * * @property-read array $other { * Extra information about event. * * - int quizid: the id of the quiz. * - bool requireprevious: the slot's require previous value. * } * * @package mod_quiz * @copyright 2021 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class slot_requireprevious_updated extends \core\event\base { protected function init() { $this->data['objecttable'] = 'quiz_slots'; $this->data['crud'] = 'u'; $this->data['edulevel'] = self::LEVEL_TEACHING; } public static function get_name() { return get_string('eventslotrequirepreviousupdated', 'mod_quiz'); } public function get_description() { return "The user with id '$this->userid' updated the slot with id '{$this->objectid}' " . "belonging to the quiz with course module id '$this->contextinstanceid'. " . "Its require previous value was set to '{$this->other['requireprevious']}'."; } public function get_url() { return new \moodle_url('/mod/quiz/edit.php', [ 'cmid' => $this->contextinstanceid ]); } protected function validate_data() { parent::validate_data(); if (!isset($this->objectid)) { throw new \coding_exception('The \'objectid\' value must be set.'); } if (!isset($this->contextinstanceid)) { throw new \coding_exception('The \'contextinstanceid\' value must be set.'); } if (!isset($this->other['quizid'])) { throw new \coding_exception('The \'quizid\' value must be set in other.'); } if (!isset($this->other['requireprevious'])) { throw new \coding_exception('The \'requireprevious\' value must be set in other.'); } } public static function get_objectid_mapping() { return ['db' => 'quiz_slots', 'restore' => 'quiz_question_instance']; } public static function get_other_mapping() { $othermapped = []; $othermapped['quizid'] = ['db' => 'quiz', 'restore' => 'quiz']; return $othermapped; } } PK9B\K!classes/event/attempt_updated.phpnu[. /** * The mod_quiz attempt updated event. * * @package mod_quiz * @copyright 2021 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace mod_quiz\event; /** * The mod_quiz attempt updated event class. * * @property-read array $other { * Extra information about event. * * - int quizid: the id of the quiz. * - int page: the page number of attempt. * } * * @package mod_quiz * @copyright 2021 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class attempt_updated extends \core\event\base { /** * Init method. */ protected function init() { $this->data['objecttable'] = 'quiz_attempts'; $this->data['crud'] = 'u'; $this->data['edulevel'] = self::LEVEL_PARTICIPATING; } /** * Returns localised general event name. * * @return string */ public static function get_name() { return get_string('eventattemptupdated', 'mod_quiz'); } /** * Returns description of what happened. * * @return string */ public function get_description() { $pagenumber = $this->other['page'] + 1; return "The user with id '$this->userid' has updated responses on page '{$pagenumber}' of the attempt " . "with id '$this->objectid' belonging to the user " . "with id '$this->relateduserid' for the quiz with course module id '$this->contextinstanceid'."; } /** * Returns relevant URL. * * @return \moodle_url */ public function get_url() { return new \moodle_url('/mod/quiz/review.php', [ 'attempt' => $this->objectid, 'page' => $this->other['page'] ]); } /** * Custom validation. * * @throws \coding_exception * @return void */ protected function validate_data() { parent::validate_data(); if (!isset($this->relateduserid)) { throw new \coding_exception('The \'relateduserid\' must be set.'); } if (!isset($this->other['quizid'])) { throw new \coding_exception('The \'quizid\' value must be set in other.'); } if (!isset($this->other['page'])) { throw new \coding_exception('The \'page\' value must be set in other.'); } } /** * This is used when restoring course logs where it is required that we * map the objectid to it's new value in the new course. * * @return array Mapping of object id. */ public static function get_objectid_mapping() { return ['db' => 'quiz_attempts', 'restore' => 'quiz_attempt']; } /** * This is used when restoring course logs where it is required that we * map the information in 'other' to it's new value in the new course. * * @return array List of mapping of other ids. */ public static function get_other_mapping() { $othermapped = []; $othermapped['quizid'] = ['db' => 'quiz', 'restore' => 'quiz']; return $othermapped; } } PK9B\,,'classes/event/section_title_updated.phpnu[. /** * The mod_quiz section title updated event. * * @package mod_quiz * @copyright 2021 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace mod_quiz\event; /** * The mod_quiz section title updated event class. * * @property-read array $other { * Extra information about event. * * - int quizid: the id of the quiz. * - string newtitle: new title. * - int firstslotid: id of the slot which is right after the section break. * - int firstslotnumber: slot number of the slot which is right after the section break. * } * * @package mod_quiz * @copyright 2021 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class section_title_updated extends \core\event\base { protected function init() { $this->data['objecttable'] = 'quiz_sections'; $this->data['crud'] = 'u'; $this->data['edulevel'] = self::LEVEL_TEACHING; } public static function get_name() { return get_string('eventsectiontitleupdated', 'mod_quiz'); } public function get_description() { $description = "The user with id '$this->userid' updated the section with id '{$this->objectid}' "; if ($this->other['firstslotid'] && $this->other['firstslotnumber']) { $description .= "before the slot with id '{$this->other['firstslotid']}' " . "and slot number '{$this->other['firstslotnumber']}' "; } $description .= "belonging to the quiz with course module id '$this->contextinstanceid'. " . "Its title was changed to '{$this->other['newtitle']}'."; return $description; } public function get_url() { return new \moodle_url('/mod/quiz/edit.php', [ 'cmid' => $this->contextinstanceid ]); } protected function validate_data() { parent::validate_data(); if (!isset($this->objectid)) { throw new \coding_exception('The \'objectid\' value must be set.'); } if (!isset($this->contextinstanceid)) { throw new \coding_exception('The \'contextinstanceid\' value must be set.'); } if (!isset($this->other['quizid'])) { throw new \coding_exception('The \'quizid\' value must be set in other.'); } if (!isset($this->other['newtitle'])) { throw new \coding_exception('The \'newtitle\' value must be set in other.'); } } public static function get_objectid_mapping() { return ['db' => 'quiz_sections', 'restore' => 'quiz_section']; } public static function get_other_mapping() { $othermapped = []; $othermapped['quizid'] = ['db' => 'quiz', 'restore' => 'quiz']; $othermapped['firstslotid'] = ['db' => 'quiz_slots', 'restore' => 'quiz_question_instance']; return $othermapped; } } PK9B\;J9 9 !classes/event/attempt_deleted.phpnu[. /** * The mod_quiz attempt deleted event. * * @package mod_quiz * @copyright 2014 Mark Nelson * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace mod_quiz\event; defined('MOODLE_INTERNAL') || die(); /** * The mod_quiz attempt deleted event class. * * @property-read array $other { * Extra information about event. * * - int quizid: the id of the quiz. * } * * @package mod_quiz * @since Moodle 2.7 * @copyright 2014 Mark Nelson * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class attempt_deleted extends \core\event\base { /** * Init method. */ protected function init() { $this->data['objecttable'] = 'quiz_attempts'; $this->data['crud'] = 'd'; $this->data['edulevel'] = self::LEVEL_TEACHING; } /** * Returns localised general event name. * * @return string */ public static function get_name() { return get_string('eventattemptdeleted', 'mod_quiz'); } /** * Returns description of what happened. * * @return string */ public function get_description() { return "The user with id '$this->userid' deleted the attempt with id '$this->objectid' belonging to the quiz " . "with course module id '$this->contextinstanceid' for the user with id '$this->relateduserid'."; } /** * Returns relevant URL. * * @return \moodle_url */ public function get_url() { return new \moodle_url('/mod/quiz/report.php', ['id' => $this->contextinstanceid]); } /** * Custom validation. * * @throws \coding_exception * @return void */ protected function validate_data() { parent::validate_data(); if (!isset($this->relateduserid)) { throw new \coding_exception('The \'relateduserid\' must be set.'); } if (!isset($this->other['quizid'])) { throw new \coding_exception('The \'quizid\' value must be set in other.'); } } public static function get_objectid_mapping() { return ['db' => 'quiz_attempts', 'restore' => 'quiz_attempt']; } public static function get_other_mapping() { $othermapped = []; $othermapped['quizid'] = ['db' => 'quiz', 'restore' => 'quiz']; return $othermapped; } } PK9B\G\\&classes/event/slot_version_updated.phpnu[. declare(strict_types=1); namespace mod_quiz\event; use core\exception\coding_exception; use core\url; /** * The question version of a slot has changed. * * @property-read array $other { * Extra information about event. * * - int quizid: the id of the quiz. * - int previousversion: the previous question version. * - int newversion: the new question version. * } * * @package mod_quiz * @copyright 2024 Catalyst IT Australia Pty Ltd * @author Cameron Ball * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class slot_version_updated extends \core\event\base { #[\Override] protected function init(): void { $this->data['objecttable'] = 'quiz_slots'; $this->data['crud'] = 'u'; $this->data['edulevel'] = self::LEVEL_TEACHING; } #[\Override] public static function get_name(): string { return get_string('eventslotversionupdated', 'mod_quiz'); } #[\Override] public function get_description(): string { $previousversion = $this->other['previousversion'] ?? 'Always latest'; $newversion = $this->other['newversion'] ?? 'Always latest'; return "The user with id '$this->userid' updated the slot with id '{$this->objectid}' " . "belonging to the quiz with course module id '$this->contextinstanceid'. " . "Its question version was changed from '$previousversion' to '$newversion'."; } #[\Override] public function get_url(): url { return new url('/mod/quiz/edit.php', ['cmid' => $this->contextinstanceid]); } #[\Override] protected function validate_data(): void { parent::validate_data(); if (!isset($this->objectid)) { throw new coding_exception('The \'objectid\' value must be set.'); } if (!isset($this->contextinstanceid)) { throw new coding_exception('The \'contextinstanceid\' value must be set.'); } if (!isset($this->other['quizid'])) { throw new coding_exception('The \'quizid\' value must be set in other.'); } // The value of previousversion and newversion can be null, so we check if // the array key exists. if (!array_key_exists('previousversion', $this->other)) { throw new coding_exception('The \'previousversion\' value must be set in other.'); } if (!array_key_exists('newversion', $this->other)) { throw new coding_exception('The \'newversion\' value must be set in other.'); } } #[\Override] public static function get_objectid_mapping(): array { return ['db' => 'quiz_slots', 'restore' => 'quiz_question_instance']; } #[\Override] public static function get_other_mapping(): array { $othermapped = []; $othermapped['quizid'] = ['db' => 'quiz', 'restore' => 'quiz']; return $othermapped; } } PK9B\S  (classes/event/attempt_summary_viewed.phpnu[. /** * The mod_quiz attempt summary viewed event. * * @package mod_quiz * @copyright 2014 Mark Nelson * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace mod_quiz\event; defined('MOODLE_INTERNAL') || die(); /** * The mod_quiz attempt summary viewed event class. * * @property-read array $other { * Extra information about event. * * - int quizid: the id of the quiz. * } * * @package mod_quiz * @since Moodle 2.7 * @copyright 2014 Mark Nelson * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class attempt_summary_viewed extends \core\event\base { /** * Init method. * * @return void */ protected function init() { $this->data['objecttable'] = 'quiz_attempts'; $this->data['crud'] = 'r'; $this->data['edulevel'] = self::LEVEL_PARTICIPATING; } /** * Return localised event name. * * @return string */ public static function get_name() { return get_string('eventattemptsummaryviewed', 'mod_quiz'); } /** * Returns description of what happened. * * @return string */ public function get_description() { return "The user with id '$this->userid' has viewed the summary for the attempt with id '$this->objectid' belonging " . "to the user with id '$this->relateduserid' for the quiz with course module id '$this->contextinstanceid'."; } /** * Get URL related to the action. * * @return \moodle_url */ public function get_url() { return new \moodle_url('/mod/quiz/summary.php', ['attempt' => $this->objectid]); } /** * Custom validation. * * @throws \coding_exception * @return void */ protected function validate_data() { parent::validate_data(); if (!isset($this->relateduserid)) { throw new \coding_exception('The \'relateduserid\' must be set.'); } if (!isset($this->other['quizid'])) { throw new \coding_exception('The \'quizid\' must be set in other.'); } } public static function get_objectid_mapping() { return ['db' => 'quiz_attempts', 'restore' => 'quiz_attempt']; } public static function get_other_mapping() { $othermapped = []; $othermapped['quizid'] = ['db' => 'quiz', 'restore' => 'quiz']; return $othermapped; } } PK9B\kvTK K )classes/event/section_shuffle_updated.phpnu[. /** * The mod_quiz section shuffle updated event. * * @package mod_quiz * @copyright 2021 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace mod_quiz\event; /** * The mod_quiz section shuffle updated event class. * * @property-read array $other { * Extra information about event. * * - int quizid: the id of the quiz. * - bool shuffle: shuffle option value. * - int firstslotnumber: slot number of the slot which is right after the section break. * } * * @package mod_quiz * @copyright 2021 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class section_shuffle_updated extends \core\event\base { protected function init() { $this->data['objecttable'] = 'quiz_sections'; $this->data['crud'] = 'u'; $this->data['edulevel'] = self::LEVEL_TEACHING; } public static function get_name() { return get_string('eventsectionshuffleupdated', 'mod_quiz'); } public function get_description() { return "The user with id '$this->userid' updated the section with id '{$this->objectid}' " . "before the slot number '{$this->other['firstslotnumber']}' " . "belonging to the quiz with course module id '$this->contextinstanceid'. " . "Its shuffle option was set to '{$this->other['shuffle']}'."; } public function get_url() { return new \moodle_url('/mod/quiz/edit.php', [ 'cmid' => $this->contextinstanceid ]); } protected function validate_data() { parent::validate_data(); if (!isset($this->objectid)) { throw new \coding_exception('The \'objectid\' value must be set.'); } if (!isset($this->contextinstanceid)) { throw new \coding_exception('The \'contextinstanceid\' value must be set.'); } if (!isset($this->other['quizid'])) { throw new \coding_exception('The \'quizid\' value must be set in other.'); } if (!isset($this->other['firstslotnumber'])) { throw new \coding_exception('The \'firstslotnumber\' value must be set in other.'); } if (!isset($this->other['shuffle'])) { throw new \coding_exception('The \'shuffle\' value must be set in other.'); } } public static function get_objectid_mapping() { return ['db' => 'quiz_sections', 'restore' => 'quiz_section']; } public static function get_other_mapping() { $othermapped = []; $othermapped['quizid'] = ['db' => 'quiz', 'restore' => 'quiz']; return $othermapped; } } PK9B\H--,classes/event/attempt_question_restarted.phpnu[. /** * The mod_quiz attempt question restarted event. * * @package mod_quiz * @copyright 2021 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace mod_quiz\event; /** * The mod_quiz attempt question restarted event class. * * @property-read array $other { * Extra information about event. * * - int quizid: the id of the quiz. * - int page: the page number of attempt. * } * * @package mod_quiz * @copyright 2021 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class attempt_question_restarted extends \core\event\base { /** * Init method. */ protected function init() { $this->data['objecttable'] = 'quiz_attempts'; $this->data['crud'] = 'u'; $this->data['edulevel'] = self::LEVEL_PARTICIPATING; } /** * Returns localised general event name. * * @return string */ public static function get_name() { return get_string('eventattemptquestionrestarted', 'mod_quiz'); } /** * Returns description of what happened. * * @return string */ public function get_description() { $pagenumber = $this->other['page'] + 1; return "The user with id '$this->userid' has restarted question at slot '{$this->other['slot']}' on page " . "'{$pagenumber}' of the attempt with id '$this->objectid' belonging to the user " . "with id '$this->relateduserid' for the quiz with course module id '$this->contextinstanceid', " . "and the new question id is '{$this->other['newquestionid']}'."; } /** * Returns relevant URL. * * @return \moodle_url */ public function get_url() { return new \moodle_url('/mod/quiz/review.php', [ 'attempt' => $this->objectid, 'page' => $this->other['page'] ]); } /** * Custom validation. * * @throws \coding_exception * @return void */ protected function validate_data() { parent::validate_data(); if (!isset($this->relateduserid)) { throw new \coding_exception('The \'relateduserid\' must be set.'); } if (!isset($this->other['quizid'])) { throw new \coding_exception('The \'quizid\' value must be set in other.'); } if (!isset($this->other['page'])) { throw new \coding_exception('The \'page\' value must be set in other.'); } if (!isset($this->other['slot'])) { throw new \coding_exception('The \'slot\' value must be set in other.'); } if (!isset($this->other['newquestionid'])) { throw new \coding_exception('The \'newquestionid\' value must be set in other.'); } } /** * This is used when restoring course logs where it is required that we * map the information in 'other' to it's new value in the new course. * * @return array List of mapping of other ids. */ public static function get_objectid_mapping() { return ['db' => 'quiz_attempts', 'restore' => 'quiz_attempt']; } /** * This is used when restoring course logs where it is required that we * map the information in 'other' to it's new value in the new course. * * @return array List of mapping of other ids. */ public static function get_other_mapping() { $othermapped = []; $othermapped['quizid'] = ['db' => 'quiz', 'restore' => 'quiz']; $othermapped['newquestionid'] = ['db' => 'question', 'restore' => 'question']; return $othermapped; } } PK9B\L'classes/event/section_break_created.phpnu[. /** * The mod_quiz section break created event. * * @package mod_quiz * @copyright 2021 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace mod_quiz\event; /** * The mod_quiz section break created event class. * * @property-read array $other { * Extra information about event. * * - int quizid: the id of the quiz. * - int firstslotid: id of the slot which we will add the section break before. * - int firstslotnumber: slot number of the slot which we will add the section break before. * - string title: the title of new section. * } * * @package mod_quiz * @copyright 2021 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class section_break_created extends \core\event\base { protected function init() { $this->data['objecttable'] = 'quiz_sections'; $this->data['crud'] = 'c'; $this->data['edulevel'] = self::LEVEL_TEACHING; } public static function get_name() { return get_string('eventsectionbreakcreated', 'mod_quiz'); } public function get_description() { return "The user with id '$this->userid' created a new section break with id '{$this->objectid}' " . "and title '{$this->other['title']}' before the slot with id '{$this->other['firstslotid']}' " . "and slot number '{$this->other['firstslotnumber']}' " . "belonging to the quiz with course module id '$this->contextinstanceid'."; } public function get_url() { return new \moodle_url('/mod/quiz/edit.php', [ 'cmid' => $this->contextinstanceid ]); } protected function validate_data() { parent::validate_data(); if (!isset($this->objectid)) { throw new \coding_exception('The \'objectid\' value must be set.'); } if (!isset($this->contextinstanceid)) { throw new \coding_exception('The \'contextinstanceid\' value must be set.'); } if (!isset($this->other['quizid'])) { throw new \coding_exception('The \'quizid\' value must be set in other.'); } if (!isset($this->other['firstslotid'])) { throw new \coding_exception('The \'firstslotid\' value must be set in other.'); } if (!isset($this->other['firstslotnumber'])) { throw new \coding_exception('The \'firstslotnumber\' value must be set in other.'); } if (!isset($this->other['title'])) { throw new \coding_exception('The \'title\' value must be set in other.'); } } public static function get_objectid_mapping() { return ['db' => 'quiz_sections', 'restore' => 'quiz_section']; } public static function get_other_mapping() { $othermapped = []; $othermapped['quizid'] = ['db' => 'quiz', 'restore' => 'quiz']; $othermapped['firstslotid'] = ['db' => 'quiz_slots', 'restore' => 'quiz_question_instance']; return $othermapped; } } PK9B\xHĮ "classes/event/quiz_repaginated.phpnu[. /** * The mod_quiz quiz re-paginated event. * * @package mod_quiz * @copyright 2021 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace mod_quiz\event; /** * The mod_quiz quiz re-paginated event class. * * @property-read array $other { * Extra information about event. * * - int slotsperpage: the slot number per page option. * } * * @package mod_quiz * @copyright 2021 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class quiz_repaginated extends \core\event\base { protected function init() { $this->data['objecttable'] = 'quiz'; $this->data['crud'] = 'u'; $this->data['edulevel'] = self::LEVEL_TEACHING; } public static function get_name() { return get_string('eventquizrepaginated', 'mod_quiz'); } public function get_description() { return "The user with id '$this->userid' re-paginated the quiz with course module id '$this->contextinstanceid' " . " with the new option '{$this->other['slotsperpage']}' slot(s) per page."; } public function get_url() { return new \moodle_url('/mod/quiz/edit.php', [ 'cmid' => $this->contextinstanceid ]); } protected function validate_data() { parent::validate_data(); if (!isset($this->objectid)) { throw new \coding_exception('The \'objectid\' value must be set.'); } if (!isset($this->contextinstanceid)) { throw new \coding_exception('The \'contextinstanceid\' value must be set.'); } if (!isset($this->other['slotsperpage'])) { throw new \coding_exception('The \'slotsperpage\' value must be set in other.'); } } public static function get_objectid_mapping() { return ['db' => 'quiz', 'restore' => 'quiz']; } public static function get_other_mapping() { return []; } } PK9B\/_44,classes/event/quiz_grade_items_reordered.phpnu[. namespace mod_quiz\event; /** * Event to record a quiz grade item being re-ordered. * * @property-read array $other { * } * * @package mod_quiz * @copyright 2023 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class quiz_grade_items_reordered extends \core\event\base { protected function init() { $this->data['objecttable'] = 'quiz'; $this->data['crud'] = 'u'; $this->data['edulevel'] = self::LEVEL_TEACHING; } public static function get_name() { return get_string('eventquizgradeitemorderchanged', 'mod_quiz'); } public function get_description() { return "The user with id '$this->userid' re-ordered the grade items " . "for the quiz with course module id '$this->contextinstanceid'."; } public function get_url() { return new \moodle_url('/mod/quiz/editgrading.php', [ 'cmid' => $this->contextinstanceid, ]); } protected function validate_data() { parent::validate_data(); if (!isset($this->contextinstanceid)) { throw new \coding_exception('The \'contextinstanceid\' value must be set.'); } } public static function get_objectid_mapping() { return ['db' => 'quiz', 'restore' => 'quiz']; } public static function get_other_mapping() { return []; } } PK9B\mŲ. . )classes/event/quiz_grade_item_updated.phpnu[. namespace mod_quiz\event; /** * Event to record a quiz grade item being updated. * * @property-read array $other { * } * * @package mod_quiz * @copyright 2023 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class quiz_grade_item_updated extends \core\event\base { protected function init() { $this->data['objecttable'] = 'quiz_grade_items'; $this->data['crud'] = 'u'; $this->data['edulevel'] = self::LEVEL_TEACHING; } public static function get_name() { return get_string('eventquizgradeitemupdated', 'mod_quiz'); } public function get_description() { return "The user with id '$this->userid' updated quiz grade item with id '$this->objectid' " . "for the quiz with course module id '$this->contextinstanceid'."; } public function get_url() { return new \moodle_url('/mod/quiz/editgrading.php', [ 'cmid' => $this->contextinstanceid, ]); } protected function validate_data() { parent::validate_data(); if (!isset($this->objectid)) { throw new \coding_exception('The \'objectid\' value must be set.'); } if (!isset($this->contextinstanceid)) { throw new \coding_exception('The \'contextinstanceid\' value must be set.'); } } public static function get_objectid_mapping() { return ['db' => 'quiz_grade_items', 'restore' => 'quiz_grade_items']; } public static function get_other_mapping() { return [ 'quizid' => ['db' => 'quiz', 'restore' => 'quiz'], ]; } } PK9B\- classes/event/slot_deleted.phpnu[. /** * The mod_quiz slots deleted event. * * @package mod_quiz * @copyright 2021 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace mod_quiz\event; /** * The mod_quiz slot deleted event class. * * @property-read array $other { * Extra information about event. * * - int quizid: the id of the quiz. * - int slotnumber: the slot number in quiz. * } * * @package mod_quiz * @copyright 2021 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class slot_deleted extends \core\event\base { protected function init() { $this->data['objecttable'] = 'quiz_slots'; $this->data['crud'] = 'd'; $this->data['edulevel'] = self::LEVEL_TEACHING; } public static function get_name() { return get_string('eventslotdeleted', 'mod_quiz'); } public function get_description() { return "The user with id '$this->userid' deleted the slot with id '{$this->objectid}' " . "and slot number '{$this->other['slotnumber']}' " . "from the quiz with course module id '$this->contextinstanceid'."; } public function get_url() { return new \moodle_url('/mod/quiz/edit.php', [ 'cmid' => $this->contextinstanceid ]); } protected function validate_data() { parent::validate_data(); if (!isset($this->objectid)) { throw new \coding_exception('The \'objectid\' value must be set.'); } if (!isset($this->contextinstanceid)) { throw new \coding_exception('The \'contextinstanceid\' value must be set.'); } if (!isset($this->other['quizid'])) { throw new \coding_exception('The \'quizid\' value must be set in other.'); } if (!isset($this->other['slotnumber'])) { throw new \coding_exception('The \'slotnumber\' value must be set in other.'); } } public static function get_objectid_mapping() { return ['db' => 'quiz_slots', 'restore' => 'quiz_question_instance']; } public static function get_other_mapping() { $othermapped = []; $othermapped['quizid'] = ['db' => 'quiz', 'restore' => 'quiz']; return $othermapped; } } PK9B\ z5 5 "classes/event/attempt_reviewed.phpnu[. /** * The mod_quiz attempt reviewed event. * * @package mod_quiz * @copyright 2014 Mark Nelson * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace mod_quiz\event; defined('MOODLE_INTERNAL') || die(); /** * The mod_quiz attempt reviewed event class. * * @property-read array $other { * Extra information about event. * * - int quizid: the id of the quiz. * } * * @package mod_quiz * @since Moodle 2.7 * @copyright 2014 Mark Nelson * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class attempt_reviewed extends \core\event\base { /** * Init method. */ protected function init() { $this->data['objecttable'] = 'quiz_attempts'; $this->data['crud'] = 'r'; $this->data['edulevel'] = self::LEVEL_PARTICIPATING; } /** * Returns localised general event name. * * @return string */ public static function get_name() { return get_string('eventattemptreviewed', 'mod_quiz'); } /** * Returns description of what happened. * * @return string */ public function get_description() { return "The user with id '$this->userid' has reviewed quiz attempt with id '$this->objectid' by user ". "with id '$this->relateduserid' for the quiz with course module id '$this->contextinstanceid'."; } /** * Returns relevant URL. * * @return \moodle_url */ public function get_url() { return new \moodle_url('/mod/quiz/review.php', ['attempt' => $this->objectid]); } /** * Custom validation. * * @throws \coding_exception * @return void */ protected function validate_data() { parent::validate_data(); if (!isset($this->relateduserid)) { throw new \coding_exception('The \'relateduserid\' must be set.'); } if (!isset($this->other['quizid'])) { throw new \coding_exception('The \'quizid\' value must be set in other.'); } } public static function get_objectid_mapping() { return ['db' => 'quiz_attempts', 'restore' => 'quiz_attempt']; } public static function get_other_mapping() { $othermapped = []; $othermapped['quizid'] = ['db' => 'quiz', 'restore' => 'quiz']; return $othermapped; } } PK9B\:'l $classes/event/quiz_grade_updated.phpnu[. /** * The mod_quiz quiz grade updated event. * * @package mod_quiz * @copyright 2021 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace mod_quiz\event; /** * The mod_quiz quiz grade updated event class. * * @property-read array $other { * Extra information about event. * * - int newgrade: the new maximum grade value. * - int oldgrade: the old maximum grade value. * } * * @package mod_quiz * @copyright 2021 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class quiz_grade_updated extends \core\event\base { protected function init() { $this->data['objecttable'] = 'quiz'; $this->data['crud'] = 'u'; $this->data['edulevel'] = self::LEVEL_TEACHING; } public static function get_name() { return get_string('eventquizgradeupdated', 'mod_quiz'); } public function get_description() { return "The user with id '$this->userid' updated the maximum grade for the quiz with " . "course module id '$this->contextinstanceid'. " . "The maximum grade was changed from '{$this->other['oldgrade']}' to '{$this->other['newgrade']}'."; } public function get_url() { return new \moodle_url('/mod/quiz/edit.php', [ 'cmid' => $this->contextinstanceid, ]); } protected function validate_data() { parent::validate_data(); if (!isset($this->objectid)) { throw new \coding_exception('The \'objectid\' value must be set.'); } if (!isset($this->contextinstanceid)) { throw new \coding_exception('The \'contextinstanceid\' value must be set.'); } if (!isset($this->other['oldgrade'])) { throw new \coding_exception('The \'oldgrade\' value must be set in other.'); } if (!isset($this->other['newgrade'])) { throw new \coding_exception('The \'newgrade\' value must be set in other.'); } } public static function get_objectid_mapping() { return ['db' => 'quiz', 'restore' => 'quiz']; } public static function get_other_mapping() { return []; } } PK9B\vըMM4classes/event/course_module_instance_list_viewed.phpnu[. /** * The mod_quiz instance list viewed event. * * @package mod_quiz * @copyright 2014 Mark Nelson * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace mod_quiz\event; defined('MOODLE_INTERNAL') || die(); /** * The mod_quiz instance list viewed event class. * * @package mod_quiz * @since Moodle 2.7 * @copyright 2014 Mark Nelson * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class course_module_instance_list_viewed extends \core\event\course_module_instance_list_viewed { // No code required here as the parent class handles it all. } PK9B\Fx,EE7classes/task/queue_all_quiz_open_notification_tasks.phpnu[. namespace mod_quiz\task; use core\task\scheduled_task; use mod_quiz\notification_helper; /** * Scheduled task to queue tasks for notifying about quizzes with an approaching open date. * * @package mod_quiz * @copyright 2024 David Woloszyn * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class queue_all_quiz_open_notification_tasks extends scheduled_task { public function get_name(): string { return get_string('sendnotificationopendatesoon', 'mod_quiz'); } public function execute(): void { $quizzes = notification_helper::get_quizzes_within_date_range(); foreach ($quizzes as $quiz) { $task = new queue_quiz_open_notification_tasks_for_users(); $task->set_custom_data($quiz); \core\task\manager::queue_adhoc_task($task, true); } $quizzes->close(); } } PK9B\dd=classes/task/quiz_notify_attempt_manual_grading_completed.phpnu[. namespace mod_quiz\task; defined('MOODLE_INTERNAL') || die(); use context_module; use core_user; use mod_quiz\quiz_attempt; use moodle_recordset; use question_display_options; use mod_quiz\question\display_options; require_once($CFG->dirroot . '/mod/quiz/locallib.php'); /** * Cron Quiz Notify Attempts Graded Task. * * @package mod_quiz * @copyright 2021 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * */ class quiz_notify_attempt_manual_grading_completed extends \core\task\scheduled_task { /** * @var int|null For using in unit testing only. Override the time we consider as now. */ protected $forcedtime = null; #[\Override] public function get_name(): string { return get_string('notifyattemptsgradedtask', 'mod_quiz'); } /** * To let this class be unit tested, we wrap all accesses to the current time in this method. * * @return int The current time. */ protected function get_time(): int { if (PHPUNIT_TEST && $this->forcedtime !== null) { return $this->forcedtime; } return time(); } /** * For testing only, pretend the current time is different. * * @param int $time The time to set as the current time. */ public function set_time_for_testing(int $time): void { if (!PHPUNIT_TEST) { throw new \coding_exception('set_time_for_testing should only be used in unit tests.'); } $this->forcedtime = $time; } #[\Override] public function execute(): void { global $DB; mtrace('Looking for quiz attempts which may need a graded notification sent...'); $attempts = $this->get_list_of_attempts(); $course = null; $quiz = null; $cm = null; foreach ($attempts as $attempt) { mtrace('Checking attempt ' . $attempt->id . ' at quiz ' . $attempt->quiz . '.'); if (!$quiz || $attempt->quiz != $quiz->id) { $quiz = $DB->get_record('quiz', ['id' => $attempt->quiz], '*', MUST_EXIST); $cm = get_coursemodule_from_instance('quiz', $attempt->quiz); $quizcontext = context_module::instance($cm->id); } if (!$course || $course->id != $quiz->course) { $course = get_course($quiz->course); } $quiz = quiz_update_effective_access($quiz, $attempt->userid); $attemptobj = new quiz_attempt($attempt, $quiz, $cm, $course, false); $options = display_options::make_from_quiz($quiz, quiz_attempt_state($quiz, $attempt)); if ($options->manualcomment == question_display_options::HIDDEN) { // User cannot currently see the feedback, so don't message them. // However, this may change in future, so leave them on the list. mtrace('Not sending an email because manualcomment review option is not set.'); continue; } if (!has_capability('mod/quiz:emailnotifyattemptgraded', $quizcontext, $attempt->userid, false)) { // User not eligible to get a notification. Mark them done while doing nothing. mtrace('Not sending an email because user does not have mod/quiz:emailnotifyattemptgraded capability.'); $DB->set_field('quiz_attempts', 'gradednotificationsenttime', $attempt->timefinish, ['id' => $attempt->id]); continue; } // OK, send notification. mtrace('Sending email to user ' . $attempt->userid . '...'); $ok = quiz_send_notify_manual_graded_message($attemptobj, core_user::get_user($attempt->userid)); if ($ok) { mtrace('Send email successfully!'); $attempt->gradednotificationsenttime = $this->get_time(); $DB->set_field('quiz_attempts', 'gradednotificationsenttime', $attempt->gradednotificationsenttime, ['id' => $attempt->id]); $attemptobj->fire_attempt_manual_grading_completed_event(); } } $attempts->close(); } /** * Get a number of records as an array of quiz_attempts using a SQL statement. * * @return moodle_recordset Of quiz_attempts that need to be processed. */ public function get_list_of_attempts(): moodle_recordset { global $DB; $delaytime = $this->get_time() - get_config('quiz', 'notifyattemptgradeddelay'); $sql = "SELECT qa.* FROM {quiz_attempts} qa JOIN {quiz} quiz ON quiz.id = qa.quiz WHERE qa.state = 'finished' AND qa.gradednotificationsenttime IS NULL AND qa.sumgrades IS NOT NULL AND qa.timemodified < :delaytime ORDER BY quiz.course, qa.quiz"; return $DB->get_recordset_sql($sql, ['delaytime' => $delaytime]); } } PK9B\v%%=classes/task/queue_quiz_open_notification_tasks_for_users.phpnu[. namespace mod_quiz\task; use core\task\adhoc_task; use mod_quiz\notification_helper; /** * Ad-hoc task to queue another task for notifying a user about an approaching open date. * * @package mod_quiz * @copyright 2024 David Woloszyn * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class queue_quiz_open_notification_tasks_for_users extends adhoc_task { public function execute(): void { $quizid = $this->get_custom_data()->id; $users = notification_helper::get_users_within_quiz($quizid); foreach ($users as $user) { $user->quizid = $quizid; $task = new send_quiz_open_soon_notification_to_user(); $task->set_custom_data($user); $task->set_userid($user->id); \core\task\manager::queue_adhoc_task($task, true); } } } PK9B\(classes/task/update_overdue_attempts.phpnu[. /** * Update Overdue Attempts Task * * @package mod_quiz * @copyright 2017 Michael Hughes * @author Michael Hughes * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace mod_quiz\task; use mod_quiz\quiz_attempt; use moodle_exception; use moodle_recordset; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->dirroot . '/mod/quiz/locallib.php'); /** * Update Overdue Attempts Task * * @package mod_quiz * @copyright 2017 Michael Hughes * @author Michael Hughes * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * */ class update_overdue_attempts extends \core\task\scheduled_task { public function get_name(): string { return get_string('updateoverdueattemptstask', 'mod_quiz'); } /** * Close off any overdue attempts. */ public function execute() { $timenow = time(); $processto = $timenow - get_config('quiz', 'graceperiodmin'); mtrace(' Looking for quiz overdue quiz attempts...'); list($count, $quizcount) = $this->update_all_overdue_attempts($timenow, $processto); mtrace(' Considered ' . $count . ' attempts in ' . $quizcount . ' quizzes.'); } /** * Do the processing required. * * @param int $timenow the time to consider as 'now' during the processing. * @param int $processto only process attempt with timecheckstate longer ago than this. * @return array with two elements, the number of attempt considered, and how many different quizzes that was. */ public function update_all_overdue_attempts(int $timenow, int $processto): array { global $DB; $attemptstoprocess = $this->get_list_of_overdue_attempts($processto); $course = null; $quiz = null; $cm = null; $count = 0; $quizcount = 0; foreach ($attemptstoprocess as $attemptinfo) { try { $attempt = $DB->get_record('quiz_attempts', ['id' => $attemptinfo->id], '*', IGNORE_MISSING); if (!$attempt) { continue; } $attempt->usertimeclose = $attemptinfo->usertimeclose; $attempt->usertimelimit = $attemptinfo->usertimelimit; // If we have moved on to a different quiz, fetch the new data. if (!$quiz || $attempt->quiz != $quiz->id) { $quiz = $DB->get_record('quiz', ['id' => $attempt->quiz], '*', MUST_EXIST); $cm = get_coursemodule_from_instance('quiz', $attempt->quiz); $quizcount += 1; } // If we have moved on to a different course, fetch the new data. if (!$course || $course->id != $quiz->course) { $course = get_course($quiz->course); } // Make a specialised version of the quiz settings, with the relevant overrides. $quizforuser = clone($quiz); $quizforuser->timeclose = $attempt->usertimeclose; $quizforuser->timelimit = $attempt->usertimelimit; // Trigger any transitions that are required. $attemptobj = new quiz_attempt($attempt, $quizforuser, $cm, $course); $attemptobj->handle_if_time_expired($timenow, false); $count += 1; } catch (moodle_exception $e) { // If an error occurs while processing one attempt, don't let that kill cron. mtrace("Error while processing attempt $attempt->id at $attempt->quiz quiz:"); mtrace($e->getMessage()); mtrace($e->getTraceAsString()); // Close down any currently open transactions, otherwise one error // will stop following DB changes from being committed. $DB->force_transaction_rollback(); } } $attemptstoprocess->close(); return [$count, $quizcount]; } /** * Get a recordset of all the attempts that need to be processed now. * * (Only public to allow unit testing. Do not use!) * * @param int $processto timestamp to process up to. * @return moodle_recordset of quiz_attempts that need to be processed because time has * passed, sorted by courseid then quizid. */ public function get_list_of_overdue_attempts(int $processto): moodle_recordset { global $DB; // SQL to compute timeclose and timelimit for each attempt. $quizausersql = quiz_get_attempt_usertime_sql( "iquiza.state IN ('inprogress', 'overdue') AND iquiza.timecheckstate <= :iprocessto"); // This query returns the attempt id and the relevant user time information. return $DB->get_recordset_sql(" SELECT quiza.id, quiza.quiz, quizauser.usertimeclose, quizauser.usertimelimit FROM {quiz_attempts} quiza JOIN {quiz} quiz ON quiz.id = quiza.quiz JOIN ( $quizausersql ) quizauser ON quizauser.id = quiza.id WHERE quiza.state IN ('inprogress', 'overdue') AND quiza.timecheckstate <= :processto ORDER BY quiz.course, quiza.quiz", ['processto' => $processto, 'iprocessto' => $processto]); } } PK9B\{^9classes/task/send_quiz_open_soon_notification_to_user.phpnu[. namespace mod_quiz\task; use core\task\adhoc_task; use mod_quiz\notification_helper; /** * Ad-hoc task to send a notification to a user about an approaching open date. * * @package mod_quiz * @copyright 2024 David Woloszyn * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class send_quiz_open_soon_notification_to_user extends adhoc_task { public function execute(): void { $user = $this->get_custom_data(); notification_helper::send_notification_to_user($user); } } PK9B\ӻAA#classes/task/precreate_attempts.phpnu[. namespace mod_quiz\task; use core\task\scheduled_task; use mod_quiz\quiz_settings; use question_engine; /** * Pre-create attempts for quizzes that have passed their threshold. * * @package mod_quiz * @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 precreate_attempts extends scheduled_task { /** * Create new instance of the task. * * @param int $maxruntime The number of seconds to allow the task to start processing new quizzes. */ public function __construct( /** @var int $maxruntime The number of seconds to allow the task to start processing new quizzes. */ protected int $maxruntime = 600, ) { } #[\Override] public function get_name(): string { return get_string('precreatetask', 'mod_quiz'); } /** * Pre-create quiz attempts for configured quizzes. * * Find all quizzes with timeopen where the current time is later * than timeopen-precreateperiod, the quiz has questions, but no attempts. * * If the precreateperiod setting is unlocked, also filter by quizzes with precreateattempts enabled. * * Find all the users enrolled on the course who can attempt the quiz and create an attempt * in the NOT_STARTED state. * * This will run for $this->maxruntime seconds, then stop to avoid hogging the cron process. Remaining quizzes will be * processed on subsequent runs. * * @return void */ public function execute(): void { global $DB; $starttime = time(); $precreateperiod = (int)get_config('quiz', 'precreateperiod'); $precreatedefault = (int)get_config('quiz', 'precreateattempts'); if ($precreateperiod === 0) { mtrace('Pre-creation of quiz attempts is disabled. Nothing to do.'); return; } $sql = " SELECT DISTINCT q.id, q.name, q.course, q.timeopen FROM {quiz} q JOIN {quiz_slots} qs ON q.id = qs.quizid LEFT JOIN {quiz_attempts} qa ON q.id = qa.quiz AND qa.preview = 0 WHERE qa.id IS NULL AND q.timeopen > :now AND q.timeopen < :threshold AND ( q.precreateattempts = :precreateattempts OR (1 = :precreatedefault AND q.precreateattempts IS NULL) ) ORDER BY q.timeopen ASC"; $params = [ 'now' => $starttime, 'threshold' => $starttime + $precreateperiod, 'precreateattempts' => 1, 'precreatedefault' => $precreatedefault, ]; $quizzes = $DB->get_records_sql($sql, $params); mtrace('Found ' . count($quizzes) . ' quizzes to create attempts for.'); $quizcount = 0; foreach ($quizzes as $quiz) { $transaction = $DB->start_delegated_transaction(); try { $quizstart = microtime(true); mtrace('Creating attempts for ' . $quiz->name); $attemptcount = self::precreate_attempts_for_quiz($quiz->id, $quiz->course); $quizend = microtime(true); $quizduration = round($quizend - $quizstart, 2); mtrace('Created ' . $attemptcount . ' attempts for ' . $quiz->name . ' in ' . $quizduration . ' seconds'); $quizcount++; $transaction->allow_commit(); } catch (\Throwable $e) { mtrace('Failed to create attempts for ' . $quiz->name); $transaction->rollback($e); } if (microtime(true) - $starttime > $this->maxruntime) { // Stop to let other tasks run, then do some more next run. mtrace('Time limit reached.'); break; } } mtrace('Created attempts for ' . $quizcount . ' quizzes.'); } /** * Pre-create attempts for a quiz. * * @param int $quizid * @param int $courseid * @return int The number of attempts created. */ public static function precreate_attempts_for_quiz(int $quizid, int $courseid): int { global $DB; $coursecontext = \context_course::instance($courseid); $users = get_enrolled_users($coursecontext, 'mod/quiz:attempt'); $attemptcount = 0; $timenow = time(); foreach ($users as $user) { if ($DB->record_exists('quiz_attempts', ['userid' => $user->id, 'quiz' => $quizid])) { // Last-minute safety check in case the quiz opened and the user started an attempt since the task started. continue; } $quizobj = quiz_settings::create($quizid, $user->id); $quba = question_engine::make_questions_usage_by_activity('mod_quiz', $quizobj->get_context()); $quba->set_preferred_behaviour($quizobj->get_quiz()->preferredbehaviour); $attempt = quiz_create_attempt($quizobj, 1, false, $timenow, false, $user->id); quiz_start_new_attempt( $quizobj, $quba, $attempt, 1, $timenow, ); quiz_attempt_save_not_started($quba, $attempt); $attemptcount++; } return $attemptcount; } } PK9B\P3[>>*classes/question/qubaids_for_quiz_user.phpnu[. namespace mod_quiz\question; /** * A {@see qubaid_condition} for finding all the question usages belonging to a particular user and quiz combination. * * @package mod_quiz * @category question * @copyright 2018 Andrew Nicols * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @deprecated This class was never needed because qubaids_for_users_attempts already existed and is more flexible. */ class qubaids_for_quiz_user extends qubaids_for_users_attempts { /** * Constructor. * * @param int $quizid The quiz to search. * @param int $userid The user to filter on * @param bool $includepreviews Whether to include preview attempts * @param bool $onlyfinished Whether to only include finished attempts or not */ public function __construct(int $quizid, int $userid, bool $includepreviews = true, bool $onlyfinished = false) { debugging('qubaids_for_quiz_user is deprecated. Please use qubaids_for_users_attempts instead.'); parent::__construct($quizid, $userid, $onlyfinished ? 'finished' : 'all', $includepreviews); } } PK9B\ $classes/question/display_options.phpnu[. namespace mod_quiz\question; defined('MOODLE_INTERNAL') || die(); require_once($CFG->dirroot . '/question/engine/lib.php'); /** * An extension of question_display_options that includes the extra options used by the quiz. * * @package mod_quiz * @category question * @copyright 2022 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class display_options extends \question_display_options { /** * The bitmask patterns use in the review option settings. * * In the quiz settings, the review... (e.g. reviewmarks) values are * bit patterns that allow what is visible to be change at different times. * These constants define which bit is for which time. * * @var int bit used to indicate 'during the attempt'. */ const DURING = 0x10000; /** @var int as above, bit used to indicate 'immediately after the attempt'. */ const IMMEDIATELY_AFTER = 0x01000; /** @var int as above, bit used to indicate 'later while the quiz is still open'. */ const LATER_WHILE_OPEN = 0x00100; /** @var int as above, bit used to indicate 'after the quiz is closed'. */ const AFTER_CLOSE = 0x00010; /** * @var bool if this is false, then the student is not allowed to review * anything about the attempt. */ public $attempt = true; /** * @var int whether the attempt overall feedback is visible. */ public $overallfeedback = self::VISIBLE; /** * Set up the various options from the quiz settings, and a time constant. * * @param \stdClass $quiz the quiz settings from the database. * @param int $when of the constants {@see DURING}, {@see IMMEDIATELY_AFTER}, * {@see LATER_WHILE_OPEN} or {@see AFTER_CLOSE}. * @return display_options instance of this class set up appropriately. */ public static function make_from_quiz(\stdClass $quiz, int $when): self { $options = new self(); $options->attempt = self::extract($quiz->reviewattempt, $when, true, false); $options->correctness = self::extract($quiz->reviewcorrectness, $when); $options->marks = self::extract($quiz->reviewmaxmarks, $when, self::extract($quiz->reviewmarks, $when, self::MARK_AND_MAX, self::MAX_ONLY), self::HIDDEN); $options->feedback = self::extract($quiz->reviewspecificfeedback, $when); $options->generalfeedback = self::extract($quiz->reviewgeneralfeedback, $when); $options->rightanswer = self::extract($quiz->reviewrightanswer, $when); $options->overallfeedback = self::extract($quiz->reviewoverallfeedback, $when); $options->numpartscorrect = $options->feedback; $options->manualcomment = $options->feedback; if ($quiz->questiondecimalpoints != -1) { $options->markdp = $quiz->questiondecimalpoints; } else { $options->markdp = $quiz->decimalpoints; } return $options; } /** * Helper function to return one value or another depending on whether one bit is set. * * @param int $setting the setting to unpack (e.g. $quiz->reviewmarks). * @param int $when of the constants {@see DURING}, {@see IMMEDIATELY_AFTER}, * {@see LATER_WHILE_OPEN} or {@see AFTER_CLOSE}. * @param bool|int $whenset value to return when the bit is set. * @param bool|int $whennotset value to return when the bit is set. * @return bool|int $whenset or $whennotset, depending. */ protected static function extract(int $setting, int $when, $whenset = self::VISIBLE, $whennotset = self::HIDDEN) { if ($setting & $when) { return $whenset; } else { return $whennotset; } } } PK9B\ޮ.classes/question/bank/question_name_column.phpnu[. namespace mod_quiz\question\bank; /** * A column type for the name of the question name. * * @package mod_quiz * @category question * @copyright 2009 Tim Hunt * @author 2021 Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class question_name_column extends \core_question\local\bank\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('core_question\local\bank\checkbox_column'); } if ($this->checkboxespresent) { return 'checkq' . $question->id; } else { return ''; } } protected function display_content($question, $rowclasses): void { $labelfor = $this->label_for($question); if ($labelfor) { echo \html_writer::start_tag('label', ['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'; } } PK9B\ywCC&classes/question/bank/qbank_helper.phpnu[. namespace mod_quiz\question\bank; use context_module; use core_question\local\bank\version_options; use core_question\local\bank\question_version_status; use core_question\local\bank\random_question_loader; use core_question\question_reference_manager; use qbank_tagquestion\tag_condition; use qubaid_condition; use stdClass; defined('MOODLE_INTERNAL') || die(); require_once($CFG->dirroot . '/question/engine/bank.php'); /** * Helper class for question bank and its associated data. * * @package mod_quiz * @category 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 qbank_helper { /** * 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. */ #[\core\attribute\deprecated( 'core_question\local\bank::get_version_options', since: 5.0, mdl: 'MDL-77713') ] public static function get_version_options(int $questionid): array { \core\deprecation::emit_deprecation([self::class, __FUNCTION__]); return version_options::get_version_options($questionid); } /** * Get the information about which questions should be used to create a quiz attempt. * * Each element in the returned array is indexed by slot.slot (slot number) an each object hass: * - All the field of the slot table. * - contextid for where the question(s) come from. * - category id for where the questions come from. * - For non-random questions, All the fields of the question table (but id is in questionid). * Also question version and question bankentryid. * - For random questions, filtercondition, which is also unpacked into category, randomrecurse, * randomtags, and note that these also have a ->name set and ->qtype set to 'random'. * * @param int $quizid the id of the quiz to load the data for. * @param context_module $quizcontext the context of this quiz. * @param int|null $slotid optional, if passed only load the data for this one slot (if it is in this quiz). * @return array indexed by slot, with information about the content of each slot. */ public static function get_question_structure(int $quizid, context_module $quizcontext, ?int $slotid = null): array { global $DB; $params = [ 'draft' => question_version_status::QUESTION_STATUS_DRAFT, 'quizcontextid' => $quizcontext->id, 'quizcontextid2' => $quizcontext->id, 'quizcontextid3' => $quizcontext->id, 'quizid' => $quizid, 'quizid2' => $quizid, ]; $slotidtest = ''; $slotidtest2 = ''; if ($slotid !== null) { $params['slotid'] = $slotid; $params['slotid2'] = $slotid; $slotidtest = ' AND slot.id = :slotid'; $slotidtest2 = ' AND lslot.id = :slotid2'; } // Load all the data about each slot. $slotdata = $DB->get_records_sql(" SELECT slot.slot, slot.id AS slotid, slot.page, slot.displaynumber, slot.requireprevious, slot.maxmark, slot.quizgradeitemid, qsr.filtercondition, qsr.usingcontextid, qv.status, qv.id AS versionid, qv.version, qr.version AS requestedversion, qv.questionbankentryid, q.id AS questionid, q.*, qc.id AS category, COALESCE(qc.contextid, qsr.questionscontextid) AS contextid FROM {quiz_slots} slot -- case where a particular question has been added to the quiz. LEFT JOIN {question_references} qr ON qr.usingcontextid = :quizcontextid AND qr.component = 'mod_quiz' AND qr.questionarea = 'slot' AND qr.itemid = slot.id LEFT JOIN {question_bank_entries} qbe ON qbe.id = qr.questionbankentryid -- This way of getting the latest version for each slot is a bit more complicated -- than we would like, but the simpler SQL did not work in Oracle 11.2. -- (It did work fine in Oracle 19.x, so once we have updated our min supported -- version we could consider digging the old code out of git history from -- just before the commit that added this comment. -- For relevant question_bank_entries, this gets the latest non-draft slot number. -- TODO: Optimise the query, as Oracle-specific constraints no longer apply. LEFT JOIN ( SELECT lv.questionbankentryid, MAX(CASE WHEN lv.status <> :draft THEN lv.version END) AS usableversion, MAX(lv.version) AS anyversion FROM {quiz_slots} lslot JOIN {question_references} lqr ON lqr.usingcontextid = :quizcontextid2 AND lqr.component = 'mod_quiz' AND lqr.questionarea = 'slot' AND lqr.itemid = lslot.id JOIN {question_versions} lv ON lv.questionbankentryid = lqr.questionbankentryid WHERE lslot.quizid = :quizid2 $slotidtest2 AND lqr.version IS NULL GROUP BY lv.questionbankentryid ) latestversions ON latestversions.questionbankentryid = qr.questionbankentryid LEFT JOIN {question_versions} qv ON qv.questionbankentryid = qbe.id -- Either specified version, or latest usable version, or a draft version. AND qv.version = COALESCE(qr.version, latestversions.usableversion, latestversions.anyversion) LEFT JOIN {question_categories} qc ON qc.id = qbe.questioncategoryid LEFT JOIN {question} q ON q.id = qv.questionid -- Case where a random question has been added. LEFT JOIN {question_set_references} qsr ON qsr.usingcontextid = :quizcontextid3 AND qsr.component = 'mod_quiz' AND qsr.questionarea = 'slot' AND qsr.itemid = slot.id WHERE slot.quizid = :quizid $slotidtest ORDER BY slot.slot ", $params); // Unpack the random info from question_set_reference. foreach ($slotdata as $slot) { // Ensure the right id is the id. $slot->id = $slot->slotid; if ($slot->filtercondition) { // Unpack the information about a random question. $slot->questionid = 's' . $slot->id; // Sometimes this is used as an array key, so needs to be unique. $filter = json_decode($slot->filtercondition, true); $slot->filtercondition = question_reference_manager::convert_legacy_set_reference_filter_condition($filter); $slot->category = $slot->filtercondition['filter']['category']['values'][0] ?? 0; $slot->qtype = 'random'; $slot->name = get_string('random', 'quiz'); $slot->length = 1; } else if ($slot->qtype === null) { // This question must have gone missing. Put in a placeholder. $slot->questionid = 's' . $slot->id; // Sometimes this is used as an array key, so needs to be unique. $slot->category = 0; $slot->qtype = 'missingtype'; $slot->name = get_string('missingquestion', 'quiz'); $slot->questiontext = ' '; $slot->questiontextformat = FORMAT_HTML; $slot->length = 1; } else if (!\question_bank::qtype_exists($slot->qtype)) { // Question of unknown type found in the database. Set to placeholder question types instead. $slot->originalqtype = $slot->qtype; $slot->qtype = 'missingtype'; } else { $slot->_partiallyloaded = 1; } } return $slotdata; } /** * Get this list of random selection tag ids from one of the slots returned by get_question_structure. * * @param stdClass $slotdata one of the array elements returned by get_question_structure. * @return array list of tag ids. */ public static function get_tag_ids_for_slot(stdClass $slotdata): array { $tagids = []; if (!isset($slotdata->filtercondition['filter'])) { return $tagids; } $filter = $slotdata->filtercondition['filter']; if (isset($filter['qtagids'])) { $tagids = $filter['qtagids']['values']; } return $tagids; } /** * Given a slot from the array returned by get_question_structure, describe the random question it represents. * * @param stdClass $slotdata one of the array elements returned by get_question_structure. * @return string that can be used to display the random slot. */ public static function describe_random_question(stdClass $slotdata): string { $qtagids = self::get_tag_ids_for_slot($slotdata); if ($qtagids) { $tagnames = []; $tags = \core_tag_tag::get_bulk($qtagids, 'id, name'); foreach ($tags as $tag) { $tagnames[] = $tag->name; } $description = get_string('randomqnametags', 'mod_quiz', implode(",", $tagnames)); } else { $description = get_string('randomqname', 'mod_quiz'); } return shorten_text($description, 255); } /** * Choose question for redo in a particular slot. * * @param int $quizid the id of the quiz to load the data for. * @param context_module $quizcontext the context of this quiz. * @param int $slotid optional, if passed only load the data for this one slot (if it is in this quiz). * @param qubaid_condition $qubaids attempts to consider when avoiding picking repeats of random questions. * @return int the id of the question to use. */ public static function choose_question_for_redo(int $quizid, context_module $quizcontext, int $slotid, qubaid_condition $qubaids): int { $slotdata = self::get_question_structure($quizid, $quizcontext, $slotid); $slotdata = reset($slotdata); // Non-random question. if ($slotdata->qtype != 'random') { return $slotdata->questionid; } // Random question. $randomloader = new random_question_loader($qubaids, []); $fitlercondition = $slotdata->filtercondition; $filter = $fitlercondition['filter'] ?? []; $newqusetionid = $randomloader->get_next_filtered_question_id($filter); if ($newqusetionid === null) { throw new \moodle_exception('notenoughrandomquestions', 'quiz'); } return $newqusetionid; } /** * Check all the questions in an attempt and return information about their versions. * * Once a quiz attempt has been started, it continues to use the version of each question * it was started with. This checks the version used for each question, against the * quiz settings for that slot, and returns which version would be used if the quiz * attempt was being started now. * * There are several cases for each slot: * - If this slot is currently set to use version 'Always latest' (which includes * random slots) and if there is now a newer version than the one in the attempt, * use that. * - If the slot is currently set to use a fixed version of the question, and that * is different from the version currently in the attempt, use that. * - Otherwise, use the same version. * * This is used in places like the re-grade code. * * The returned data probably contains a bit more information than is strictly needed, * (see the SQL for details) but returning a few extra ints is fast, and this could * prove invaluable when debugging. The key information is probably: * - questionattemptslot <-- array key * - questionattemptid * - currentversion * - currentquestionid * - newversion * - newquestionid * * @param stdClass $attempt a quiz_attempt database row. * @param context_module $quizcontext the quiz context for the quiz the attempt belongs to. * @return array for each question_attempt in the quiz attempt, information about whether it is using * the latest version of the question. Array indexed by questionattemptslot. */ public static function get_version_information_for_questions_in_attempt( stdClass $attempt, context_module $quizcontext, ): array { global $DB; return $DB->get_records_sql(" SELECT qa.slot AS questionattemptslot, qa.id AS questionattemptid, slot.slot AS quizslot, slot.id AS quizslotid, qr.id AS questionreferenceid, currentqv.version AS currentversion, currentqv.questionid AS currentquestionid, newqv.version AS newversion, newqv.questionid AS newquestionid -- Start with the question currently used in the attempt. FROM {question_attempts} qa JOIN {question_versions} currentqv ON currentqv.questionid = qa.questionid -- Join in the question metadata which says if this is a qa from a 'Try another question like this one'. JOIN {question_attempt_steps} firststep ON firststep.questionattemptid = qa.id AND firststep.sequencenumber = 0 LEFT JOIN {question_attempt_step_data} otherslotinfo ON otherslotinfo.attemptstepid = firststep.id AND otherslotinfo.name = :otherslotmetadataname -- Join in the quiz slot information, and hence for non-random slots, the questino_reference. JOIN {quiz_slots} slot ON slot.quizid = :quizid AND slot.slot = COALESCE({$DB->sql_cast_char2int('otherslotinfo.value', true)}, qa.slot) LEFT JOIN {question_references} qr ON qr.usingcontextid = :quizcontextid AND qr.component = 'mod_quiz' AND qr.questionarea = 'slot' AND qr.itemid = slot.id -- Finally, get the new version for this slot. JOIN {question_versions} newqv ON newqv.questionbankentryid = currentqv.questionbankentryid AND newqv.version = COALESCE( -- If the quiz setting say use a particular version, use that. qr.version, -- Otherwise, we need the latest non-draft version of the current questions. (SELECT MAX(version) FROM {question_versions} WHERE questionbankentryid = currentqv.questionbankentryid AND status <> :draft), -- Otherwise, there is not a suitable other version, so stick with the current one. currentqv.version ) -- We want this for questions in the current attempt. WHERE qa.questionusageid = :questionusageid -- Order not essential, but fast and good for debugging. ORDER BY qa.slot ", [ 'otherslotmetadataname' => ':_originalslot', 'quizid' => $attempt->quiz, 'quizcontextid' => $quizcontext->id, 'draft' => question_version_status::QUESTION_STATUS_DRAFT, 'questionusageid' => $attempt->uniqueid, ]); } } PK9B\I4= = 3classes/question/bank/question_name_text_column.phpnu[. namespace mod_quiz\question\bank; /** * A column type for the name followed by the start of the question text. * * @package mod_quiz * @category question * @copyright 2009 Tim Hunt * @author 2021 Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class question_name_text_column extends question_name_column { #[\Override] public function get_name(): string { return 'questionnametext'; } #[\Override] public function get_default_width(): int { // In the places this is used, this seems to make it use all the available space, without overflowing. return 800; } #[\Override] protected function display_content($question, $rowclasses): void { echo \html_writer::start_tag('div'); $labelfor = $this->label_for($question); if ($labelfor) { echo \html_writer::start_tag('label', ['for' => $labelfor]); } echo quiz_question_tostring($question, false, true, true, $question->tags, false); if ($labelfor) { echo \html_writer::end_tag('label'); } echo \html_writer::end_tag('div'); } #[\Override] public function get_required_fields(): array { $fields = parent::get_required_fields(); $fields[] = 'q.questiontext'; $fields[] = 'q.questiontextformat'; $fields[] = 'qbe.idnumber'; return $fields; } #[\Override] public function load_additional_data(array $questions) { parent::load_additional_data($questions); parent::load_question_tags($questions); } } PK9B\[+classes/question/bank/add_action_column.phpnu[. namespace mod_quiz\question\bank; /** * A column type for the add this question to the quiz action. * * @package mod_quiz * @category question * @copyright 2009 Tim Hunt * @author 2021 Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class add_action_column extends \core_question\local\bank\column_base { /** @var string caches a lang string used repeatedly. */ protected $stradd; public function init(): void { parent::init(); $this->stradd = get_string('addtoquiz', 'quiz'); } public function get_extra_classes(): array { return ['iconcol']; } public function get_title(): string { return ' '; } public function get_name() { return 'addtoquizaction'; } protected function display_content($question, $rowclasses) { global $OUTPUT; if (!question_has_capability_on($question, 'use')) { return; } $link = new \action_link( $this->qbank->add_to_quiz_url($question->id), '', null, ['title' => $this->stradd], new \pix_icon('t/add', $this->stradd)); echo $OUTPUT->render($link); } } PK9B\ qhh/classes/question/bank/preview_action_column.phpnu[. namespace mod_quiz\question\bank; /** * A column type for the preview question action. * * @package mod_quiz * @category question * @copyright 2023 Catalyst IT Europe Ltd. * @author Mark Johnson * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class preview_action_column extends \core_question\local\bank\column_base { public function get_extra_classes(): array { return ['iconcol']; } #[\Override] public function get_title(): string { return ' '; } #[\Override] public function get_name() { return 'previewquestionaction'; } #[\Override] public function get_default_width(): int { return 45; } #[\Override] protected function display_content($question, $rowclasses) { global $PAGE; if (!question_has_capability_on($question, 'use')) { return; } if (!\question_bank::is_qtype_usable($question->qtype)) { return; } $editrenderer = $PAGE->get_renderer('quiz', 'edit'); echo $editrenderer->question_preview_icon($this->qbank->get_quiz(), $question); } } PK9B\Tpp7classes/question/bank/quiz_managecategories_feature.phpnu[. namespace mod_quiz\question\bank; use core_question\local\bank\view; use mod_quiz\question\bank\filter\custom_category_condition; /** * Class quiz_managecategories_feature * * Overrides the default categories feature with a custom category condition. * * @package mod_quiz * @copyright 2022 Catalyst IT EU Ltd. * @author Mark Johnson * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class quiz_managecategories_feature extends \qbank_managecategories\plugin_feature { public function get_question_filters(?view $qbank = null): array { return [ new custom_category_condition($qbank), ]; } } PK9B\W^.classes/question/bank/random_question_view.phpnu[. namespace mod_quiz\question\bank; use qbank_viewquestiontype\question_type_column; /** * Subclass to customise the view of the question bank for the quiz editing screen. * * @package mod_quiz * @copyright 2022 Catalyst IT Australia Pty Ltd * @author 2022 Nathan Nguyen * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class random_question_view extends custom_view { #[\Override] protected function get_question_bank_plugins(): array { return [ new question_type_column($this), new question_name_text_column($this), new preview_action_column($this), ]; } #[\Override] protected function display_bottom_controls(\context $catcontext): void { } } PK9B\v44%classes/question/bank/custom_view.phpnu[. /** * Defines the custom question bank view used on the Edit quiz page. * * @package mod_quiz * @category 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 mod_quiz\question\bank; defined('MOODLE_INTERNAL') || die(); use core\output\datafilter; use core\output\html_writer; use core_question\local\bank\column_base; use core_question\local\bank\condition; use core_question\local\bank\column_manager_base; use core_question\local\bank\filter_condition_manager; use core_question\local\bank\question_version_status; require_once($CFG->dirroot . '/mod/quiz/locallib.php'); /** * Subclass to customise the view of the question bank for the quiz editing screen. * * @copyright 2009 Tim Hunt * @author 2021 Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class custom_view extends \core_question\local\bank\view { /** @var int number of questions per page to show in the add from question bank modal. */ const DEFAULT_PAGE_SIZE = 20; /** @var bool $quizhasattempts whether the quiz this is used by has been attemptd. */ protected $quizhasattempts = false; /** @var \stdClass $quiz the quiz settings. */ protected $quiz = false; /** * @var string $component the component the api is used from. */ public $component = 'mod_quiz'; /** * Determine if the 'switch question bank' button must be displayed. * * @var bool */ protected bool $requirebankswitch; /** * Constructor. * @param \core_question\local\bank\question_edit_contexts $contexts * @param \moodle_url $pageurl * @param \stdClass $course course settings * @param \stdClass $cm activity settings. * @param \stdClass $quiz quiz settings. */ public function __construct($contexts, $pageurl, $course, $cm, $params, $extraparams) { // Default filter condition. if (!isset($params['filter'])) { $params['filter'] = filter_condition_manager::get_default_filter($params['cat']); // The quiz question bank modal doesn't include a hidden filter option. // Therefore, the default filter hidden condition is unnecessary. unset($params['filter']['hidden']); } $this->init_columns($this->wanted_columns(), $this->heading_column()); $this->pagesize = self::DEFAULT_PAGE_SIZE; parent::__construct($contexts, $pageurl, $course, $cm, $params, $extraparams); [$this->quiz, ] = get_module_from_cmid($extraparams['quizcmid']); $this->set_quiz_has_attempts(quiz_has_attempts($this->quiz->id)); $this->requirebankswitch = $extraparams['requirebankswitch'] ?? true; } /** * Just use the base column manager in this view. * * @return void */ protected function init_column_manager(): void { $this->columnmanager = new column_manager_base(); } /** * Don't display plugin controls. * * @param \core\context $context * @param int $categoryid * @return string */ protected function get_plugin_controls(\core\context $context, int $categoryid): string { return ''; } protected function get_question_bank_plugins(): array { $questionbankclasscolumns = []; $customviewcolumns = [ 'mod_quiz\question\bank\add_action_column' . column_base::ID_SEPARATOR . 'add_action_column', 'core_question\local\bank\checkbox_column' . column_base::ID_SEPARATOR . 'checkbox_column', 'qbank_viewquestiontype\question_type_column' . column_base::ID_SEPARATOR . 'question_type_column', 'mod_quiz\question\bank\question_name_text_column' . column_base::ID_SEPARATOR . 'question_name_text_column', 'mod_quiz\question\bank\preview_action_column' . column_base::ID_SEPARATOR . 'preview_action_column', ]; foreach ($customviewcolumns as $columnid) { [$columnclass, $columnname] = explode(column_base::ID_SEPARATOR, $columnid, 2); if (class_exists($columnclass)) { $questionbankclasscolumns[$columnid] = $columnclass::from_column_name($this, $columnname); } } return $questionbankclasscolumns; } protected function heading_column(): string { return 'mod_quiz\\question\\bank\\question_name_text_column'; } protected function default_sort(): array { // Using the extended class for quiz specific sort. return [ 'qbank_viewquestiontype__question_type_column' => SORT_ASC, 'mod_quiz__question__bank__question_name_text_column' => SORT_ASC, ]; } /** * Let the question bank display know whether the quiz has been attempted, * hence whether some bits of UI, like the add this question to the quiz icon, * should be displayed. * * @param bool $quizhasattempts whether the quiz has attempts. */ private function set_quiz_has_attempts($quizhasattempts): void { $this->quizhasattempts = $quizhasattempts; if ($quizhasattempts && isset($this->visiblecolumns['addtoquizaction'])) { unset($this->visiblecolumns['addtoquizaction']); } } /** * URL of add to quiz. * * @param $questionid * @return \moodle_url */ public function add_to_quiz_url($questionid) { $params = $this->baseurl->params(); $params['addquestion'] = $questionid; $params['sesskey'] = sesskey(); $params['cmid'] = $this->cm->id; return new \moodle_url('/mod/quiz/edit.php', $params); } /** * Renders the html question bank (same as display, but returns the result). * * Note that you can only output this rendered result once per page, as * it contains IDs which must be unique. * * @param array $pagevars * @param string $tabname * @return string HTML code for the form */ public function render($pagevars, $tabname): string { ob_start(); $this->display(); $out = ob_get_contents(); ob_end_clean(); return $out; } protected function display_bottom_controls(\context $catcontext): void { $cmoptions = new \stdClass(); $cmoptions->hasattempts = !empty($this->quizhasattempts); $canuseall = has_capability('moodle/question:useall', $catcontext); echo \html_writer::start_tag('div', ['class' => 'pt-2']); if ($canuseall) { // Add selected questions to the quiz. $params = [ 'type' => 'submit', 'name' => 'add', 'class' => 'btn btn-primary', 'value' => get_string('addselectedquestionstoquiz', 'quiz'), 'data-action' => 'toggle', 'data-togglegroup' => 'qbank', 'data-toggle' => 'action', 'disabled' => true, ]; echo \html_writer::empty_tag('input', $params); } echo \html_writer::end_tag('div'); } /** * Override the base implementation in \core_question\local\bank\view * because we don't want to print new question form in the fragment * for the modal. * * @param false|mixed|\stdClass $category * @param bool $canadd */ protected function create_new_question_form($category, $canadd): void { } /** * Override the base implementation in \core_question\local\bank\view * because we don't want to print the headers in the fragment * for the modal. */ protected function display_question_bank_header(): void { } 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); } // Build the where clause. $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 AND v.status <> :substatus)'; // An additional condition is required in the subquery to account for scenarios // where the latest version is hidden. This ensures we retrieve the previous // "Ready" version instead of the hidden latest version. $onlyready = '((qv.status = :status))'; $this->sqlparams = [ 'status' => question_version_status::QUESTION_STATUS_READY, 'substatus' => question_version_status::QUESTION_STATUS_HIDDEN, ]; $conditions = []; foreach ($this->searchconditions as $searchcondition) { if ($searchcondition->where()) { $conditions[] = '((' . $searchcondition->where() .'))'; } if ($searchcondition->params()) { $this->sqlparams = array_merge($this->sqlparams, $searchcondition->params()); } } $majorconditions = ['q.parent = 0', $latestversion, $onlyready]; // 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 ' . implode(' AND ', $majorconditions); 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); } public function add_standard_search_conditions(): void { foreach ($this->plugins as $componentname => $plugin) { if (\core\plugininfo\qbank::is_plugin_enabled($componentname)) { $pluginentrypointobject = new $plugin(); if ($componentname === 'qbank_managecategories') { $pluginentrypointobject = new quiz_managecategories_feature(); } if ($componentname === 'qbank_viewquestiontext' || $componentname === 'qbank_deletequestion') { continue; } $pluginobjects = $pluginentrypointobject->get_question_filters($this); foreach ($pluginobjects as $pluginobject) { $this->add_searchcondition($pluginobject, $pluginobject->get_condition_key()); } } } } /** * Return the quiz settings for the quiz this question bank is displayed in. * * @return bool|\stdClass */ public function get_quiz() { return $this->quiz; } /** * Shows the question bank interface. * * @return void */ public function display(): void { echo \html_writer::start_div('questionbankwindow boxwidthwide boxaligncenter', [ 'data-component' => 'core_question', 'data-callback' => 'display_question_bank', 'data-contextid' => $this->contexts->lowest()->id, ]); // Show the 'switch question bank' button. echo $this->display_bank_switch(); // Show the filters and search options. $this->wanted_filters(); // Continues with list of questions. $this->display_question_list(); echo \html_writer::end_div(); } /** * Get the current bank header and bank switch button. * * @return string */ protected function display_bank_switch(): string { global $OUTPUT; if (!$this->requirebankswitch) { return ''; } $cminfo = \cm_info::create($this->cm); return $OUTPUT->render_from_template('mod_quiz/switch_bank_header', ['currentbank' => $cminfo->get_formatted_name()]); } } PK9B\>OЩAclasses/question/bank/filter/custom_category_condition_helper.phpnu[. namespace mod_quiz\question\bank\filter; use core_question\local\bank\question_version_status; /** * A custom filter condition helper for quiz to select question categories. * * This is required as quiz will only use ready questions and the count should show according to that. * * @package mod_quiz * @category 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 custom_category_condition_helper extends \qbank_managecategories\helper { public static 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 = self::get_categories_for_contexts($contextslist, 'parent, sortorder, name ASC', $top); if ($top) { $categories = self::question_fix_top_names($categories); } $categories = self::question_add_context_in_key($categories); $categories = self::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 != $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; } } public static function get_categories_for_contexts($contexts, string $sortorder = 'parent, sortorder, name ASC', bool $top = false, int $showallversions = 0): array { global $DB; $topwhere = $top ? '' : 'AND c.parent <> 0'; $statuscondition = "AND qv.status = :status"; $params = [ 'status' => question_version_status::QUESTION_STATUS_READY, 'substatus' => 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 AND v.status <> :substatus) ) ) ) AS questioncount FROM {question_categories} c WHERE c.contextid IN ($contexts) $topwhere ORDER BY $sortorder"; return $DB->get_records_sql($sql, $params); } } PK9B\t':classes/question/bank/filter/custom_category_condition.phpnu[. namespace mod_quiz\question\bank\filter; use qbank_managecategories\helper; /** * A custom filter condition for quiz to select question categories. * * This is required as quiz will only use ready questions and the count should show according to that. * * @package mod_quiz * @category 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 custom_category_condition extends \qbank_managecategories\category_condition { public function get_initial_values() { $catmenu = custom_category_condition_helper::question_category_options($this->contexts, true, 0, true, -1, false); $values = []; foreach ($catmenu as $menu) { foreach ($menu as $catlist) { foreach ($catlist as $key => $value) { $values[] = (object) [ // Remove contextid from value. 'value' => strpos($key, ',') === false ? $key : substr($key, 0, strpos($key, ',')), 'title' => $value, 'selected' => ($key === $this->cat), ]; } } } return $values; } } PK9B\;Ŧ /classes/question/qubaids_for_users_attempts.phpnu[. namespace mod_quiz\question; use mod_quiz\quiz_attempt; defined('MOODLE_INTERNAL') || die(); require_once($CFG->dirroot . '/question/engine/datalib.php'); /** * A {@see qubaid_condition} representing all the attempts by one user at a given quiz. * * @package mod_quiz * @category question * @copyright 2015 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class qubaids_for_users_attempts extends \qubaid_join { /** * Constructor. * * This takes the same arguments as {@link quiz_get_user_attempts()}. * * @param int $quizid the quiz id. * @param int $userid the userid. * @param string $status 'all', 'finished' or 'unfinished' to control * @param bool $includepreviews defaults to false. */ public function __construct($quizid, $userid, $status = 'finished', $includepreviews = false) { $where = 'quiza.quiz = :quizaquiz AND quiza.userid = :userid'; $params = ['quizaquiz' => $quizid, 'userid' => $userid]; if (!$includepreviews) { $where .= ' AND preview = 0'; } switch ($status) { case 'all': break; case 'finished': $where .= ' AND state IN (:state1, :state2)'; $params['state1'] = quiz_attempt::FINISHED; $params['state2'] = quiz_attempt::ABANDONED; break; case 'unfinished': $where .= ' AND state IN (:state1, :state2)'; $params['state1'] = quiz_attempt::IN_PROGRESS; $params['state2'] = quiz_attempt::OVERDUE; break; } parent::__construct('{quiz_attempts} quiza', 'quiza.uniqueid', $where, $params); } } PK9B\%classes/question/qubaids_for_quiz.phpnu[. namespace mod_quiz\question; use mod_quiz\quiz_attempt; defined('MOODLE_INTERNAL') || die(); require_once($CFG->dirroot . '/question/engine/datalib.php'); /** * A {@see qubaid_condition} for finding all the question usages belonging to a particular quiz. * * @package mod_quiz * @category question * @copyright 2010 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class qubaids_for_quiz extends \qubaid_join { /** * Constructor. * * @param int $quizid The quiz to search. * @param bool $includepreviews Whether to include preview attempts * @param bool $onlyfinished Whether to only include finished attempts or not */ public function __construct(int $quizid, bool $includepreviews = true, bool $onlyfinished = false) { $where = 'quiza.quiz = :quizaquiz'; $params = ['quizaquiz' => $quizid]; if (!$includepreviews) { $where .= ' AND preview = 0'; } if ($onlyfinished) { $where .= ' AND state = :statefinished'; $params['statefinished'] = quiz_attempt::FINISHED; } parent::__construct('{quiz_attempts} quiza', 'quiza.uniqueid', $where, $params); } } PK9B\8z(classes/completion/custom_completion.phpnu[. declare(strict_types=1); namespace mod_quiz\completion; use context_module; use core_completion\activity_custom_completion; use mod_quiz\quiz_settings; use mod_quiz\access_manager; /** * Activity custom completion subclass for the quiz activity. * * Class for defining mod_quiz's custom completion rules and fetching the completion statuses * of the custom completion rules for a given quiz instance and a user. * * @package mod_quiz * @copyright 2021 Shamim Rezaie * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class custom_completion extends activity_custom_completion { /** * Check passing grade (or no attempts left) requirement for completion. * * @return bool True if the passing grade (or no attempts left) requirement is disabled or met. */ protected function check_passing_grade_or_all_attempts(): bool { global $CFG; require_once($CFG->libdir . '/gradelib.php'); $completionpassorattempts = $this->cm->customdata['customcompletionrules']['completionpassorattemptsexhausted']; if (empty($completionpassorattempts['completionpassgrade'])) { return true; } if ($this->completionstate && isset($this->completionstate['passgrade']) && $this->completionstate['passgrade'] == COMPLETION_COMPLETE_PASS) { return true; } // If a passing grade is required and exhausting all available attempts is not accepted for completion, // then this quiz is not complete. if (empty($completionpassorattempts['completionattemptsexhausted'])) { return false; } // Check if all attempts are used up. $attempts = quiz_get_user_attempts($this->cm->instance, $this->userid, 'finished', true); if (!$attempts) { return false; } $lastfinishedattempt = end($attempts); $context = context_module::instance($this->cm->id); $quizobj = quiz_settings::create((int) $this->cm->instance, $this->userid); $accessmanager = new access_manager( $quizobj, time(), has_capability('mod/quiz:ignoretimelimits', $context, $this->userid, false) ); return $accessmanager->is_finished(count($attempts), $lastfinishedattempt); } /** * Check minimum attempts requirement for completion. * * @return bool True if minimum attempts requirement is disabled or met. */ protected function check_min_attempts() { $minattempts = $this->cm->customdata['customcompletionrules']['completionminattempts']; if (!$minattempts) { return true; } // Check if the user has done enough attempts. $attempts = quiz_get_user_attempts($this->cm->instance, $this->userid, 'finished', true); return $minattempts <= count($attempts); } /** * Fetches the completion state for a given completion rule. * * @param string $rule The completion rule. * @return int The completion state. */ public function get_state(string $rule): int { $this->validate_rule($rule); switch ($rule) { case 'completionpassorattemptsexhausted': $status = static::check_passing_grade_or_all_attempts(); break; case 'completionminattempts': $status = static::check_min_attempts(); break; } return empty($status) ? COMPLETION_INCOMPLETE : COMPLETION_COMPLETE; } /** * Fetch the list of custom completion rules that this module defines. * * @return array */ public static function get_defined_custom_rules(): array { return [ 'completionpassorattemptsexhausted', 'completionminattempts', ]; } /** * Returns an associative array of the descriptions of custom completion rules. * * @return array */ public function get_custom_rule_descriptions(): array { $minattempts = $this->cm->customdata['customcompletionrules']['completionminattempts'] ?? 0; $description['completionminattempts'] = get_string('completiondetail:minattempts', 'mod_quiz', $minattempts); // Completion pass grade is now part of core. Only show the following if it's combined with min attempts. $completionpassorattempts = $this->cm->customdata['customcompletionrules']['completionpassorattemptsexhausted'] ?? []; if (!empty($completionpassorattempts['completionattemptsexhausted'])) { $description['completionpassorattemptsexhausted'] = get_string('completiondetail:passorexhaust', 'mod_quiz'); } return $description; } /** * Returns an array of all completion rules, in the order they should be displayed to users. * * @return array */ public function get_sort_order(): array { return [ 'completionview', 'completionminattempts', 'completionusegrade', 'completionpassgrade', 'completionpassorattemptsexhausted', ]; } } PK9B\ewdSdSclasses/access_manager.phpnu[. namespace mod_quiz; use core_component; use mod_quiz\form\preflight_check_form; use mod_quiz\local\access_rule_base; use mod_quiz\output\renderer; use mod_quiz\question\display_options; use mod_quiz_mod_form; use moodle_page; use moodle_url; use MoodleQuickForm; use stdClass; /** * This class aggregates the access rules that apply to a particular quiz. * * This provides a convenient API which other parts of the quiz code can use * to interact with the access rules. * * @package mod_quiz * @copyright 2009 Tim Hunt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @since Moodle 2.2 */ class access_manager { /** @var quiz_settings the quiz settings object. */ protected $quizobj; /** @var int the time to be considered as 'now'. */ protected $timenow; /** @var access_rule_base instances of the active rules for this quiz. */ protected $rules = []; /** * Create an instance for a particular quiz. * * @param quiz_settings $quizobj the quiz settings. * The quiz we will be controlling access to. * @param int $timenow The time to use as 'now'. * @param bool $canignoretimelimits Whether this user is exempt from time * limits (has_capability('mod/quiz:ignoretimelimits', ...)). */ public function __construct(quiz_settings $quizobj, int $timenow, bool $canignoretimelimits) { $this->quizobj = $quizobj; $this->timenow = $timenow; $this->rules = $this->make_rules($quizobj, $timenow, $canignoretimelimits); } /** * Make all the rules relevant to a particular quiz. * * @param quiz_settings $quizobj information about the quiz in question. * @param int $timenow the time that should be considered as 'now'. * @param bool $canignoretimelimits whether the current user is exempt from * time limits by the mod/quiz:ignoretimelimits capability. * @return access_rule_base[] rules that apply to this quiz. */ protected function make_rules(quiz_settings $quizobj, int $timenow, bool $canignoretimelimits): array { $rules = []; foreach (self::get_rule_classes() as $ruleclass) { $rule = $ruleclass::make($quizobj, $timenow, $canignoretimelimits); if ($rule) { $rules[$ruleclass] = $rule; } } $superceededrules = []; foreach ($rules as $rule) { $superceededrules += $rule->get_superceded_rules(); } foreach ($superceededrules as $superceededrule) { unset($rules['quizaccess_' . $superceededrule]); } return $rules; } /** * Get that names of all the installed rule classes. * * @return array of class names. */ protected static function get_rule_classes(): array { return core_component::get_plugin_list_with_class('quizaccess', '', 'rule.php'); } /** * Add any form fields that the access rules require to the settings form. * * Note that the standard plugins do not use this mechanism, becuase all their * settings are stored in the quiz table. * * @param mod_quiz_mod_form $quizform the quiz settings form that is being built. * @param MoodleQuickForm $mform the wrapped MoodleQuickForm. */ public static function add_settings_form_fields( mod_quiz_mod_form $quizform, MoodleQuickForm $mform): void { foreach (self::get_rule_classes() as $rule) { $rule::add_settings_form_fields($quizform, $mform); } } /** * The the options for the Browser security settings menu. * * @return array key => lang string. */ public static function get_browser_security_choices(): array { $options = ['-' => get_string('none', 'quiz')]; foreach (self::get_rule_classes() as $rule) { $options += $rule::get_browser_security_choices(); } return $options; } /** * Validate the data from any form fields added using {@see add_settings_form_fields()}. * * @param array $errors the errors found so far. * @param array $data the submitted form data. * @param array $files information about any uploaded files. * @param mod_quiz_mod_form $quizform the quiz form object. * @return array $errors the updated $errors array. */ public static function validate_settings_form_fields(array $errors, array $data, array $files, mod_quiz_mod_form $quizform): array { foreach (self::get_rule_classes() as $rule) { $errors = $rule::validate_settings_form_fields($errors, $data, $files, $quizform); } return $errors; } /** * Save any submitted settings when the quiz settings form is submitted. * * Note that the standard plugins do not use this mechanism because their * settings are stored in the quiz table. * * @param stdClass $quiz the data from the quiz form, including $quiz->id * which is the id of the quiz being saved. */ public static function save_settings(stdClass $quiz): void { foreach (self::get_rule_classes() as $rule) { $rule::save_settings($quiz); } } /** * Delete any rule-specific settings when the quiz is deleted. * * Note that the standard plugins do not use this mechanism because their * settings are stored in the quiz table. * * @param stdClass $quiz the data from the database, including $quiz->id * which is the id of the quiz being deleted. * @since Moodle 2.7.1, 2.6.4, 2.5.7 */ public static function delete_settings(stdClass $quiz): void { foreach (self::get_rule_classes() as $rule) { $rule::delete_settings($quiz); } } /** * Build the SQL for loading all the access settings in one go. * * @param int $quizid the quiz id. * @param array $rules list of rule plugins, from {@see get_rule_classes()}. * @param string $basefields initial part of the select list. * @return array with two elements, the sql and the placeholder values. * If $basefields is '' then you must allow for the possibility that * there is no data to load, in which case this method returns $sql = ''. */ protected static function get_load_sql(int $quizid, array $rules, string $basefields): array { $allfields = $basefields; $alljoins = '{quiz} quiz'; $allparams = ['quizid' => $quizid]; foreach ($rules as $rule) { [$fields, $joins, $params] = $rule::get_settings_sql($quizid); if ($fields) { if ($allfields) { $allfields .= ', '; } $allfields .= $fields; } if ($joins) { $alljoins .= ' ' . $joins; } if ($params) { $allparams += $params; } } if ($allfields === '') { return ['', []]; } return ["SELECT $allfields FROM $alljoins WHERE quiz.id = :quizid", $allparams]; } /** * Load any settings required by the access rules. We try to do this with * a single DB query. * * Note that the standard plugins do not use this mechanism, becuase all their * settings are stored in the quiz table. * * @param int $quizid the quiz id. * @return array setting value name => value. The value names should all * start with the name of the corresponding plugin to avoid collisions. */ public static function load_settings(int $quizid): array { global $DB; $rules = self::get_rule_classes(); [$sql, $params] = self::get_load_sql($quizid, $rules, ''); if ($sql) { $data = (array) $DB->get_record_sql($sql, $params); } else { $data = []; } foreach ($rules as $rule) { $data += $rule::get_extra_settings($quizid); } return $data; } /** * Load the quiz settings and any settings required by the access rules. * We try to do this with a single DB query. * * Note that the standard plugins do not use this mechanism, becuase all their * settings are stored in the quiz table. * * @param int $quizid the quiz id. * @return stdClass mdl_quiz row with extra fields. */ public static function load_quiz_and_settings(int $quizid): stdClass { global $DB; $rules = self::get_rule_classes(); [$sql, $params] = self::get_load_sql($quizid, $rules, 'quiz.*'); $quiz = $DB->get_record_sql($sql, $params, MUST_EXIST); foreach ($rules as $rule) { foreach ($rule::get_extra_settings($quizid) as $name => $value) { $quiz->$name = $value; } } return $quiz; } /** * Get an array of the class names of all the active rules. * * Mainly useful for debugging. * * @return array */ public function get_active_rule_names(): array { $classnames = []; foreach ($this->rules as $rule) { $classnames[] = get_class($rule); } return $classnames; } /** * Accumulates an array of messages. * * @param array $messages the current list of messages. * @param string|array $new the new messages or messages. * @return array the updated array of messages. */ protected function accumulate_messages(array $messages, $new): array { if (is_array($new)) { $messages = array_merge($messages, $new); } else if (is_string($new) && $new) { $messages[] = $new; } return $messages; } /** * Provide a description of the rules that apply to this quiz, such * as is shown at the top of the quiz view page. Note that not all * rules consider themselves important enough to output a description. * * @return array an array of description messages which may be empty. It * would be sensible to output each one surrounded by <p> tags. */ public function describe_rules(): array { $result = []; foreach ($this->rules as $rule) { $result = $this->accumulate_messages($result, $rule->description()); } return $result; } /** * Whether a user should be allowed to start a new attempt at this quiz now. * If there are any restrictions in force now, return an array of reasons why access * should be blocked. If access is OK, return false. * * @param int $numprevattempts the number of previous attempts this user has made. * @param stdClass|false $lastattempt information about the user's last completed attempt. * if there is not a previous attempt, the false is passed. * @return array an array of reason why access is not allowed. An empty array * (== false) if access should be allowed. */ public function prevent_new_attempt(int $numprevattempts, $lastattempt): array { $reasons = []; foreach ($this->rules as $rule) { $reasons = $this->accumulate_messages($reasons, $rule->prevent_new_attempt($numprevattempts, $lastattempt)); } return $reasons; } /** * Whether the user should be blocked from starting a new attempt or continuing * an attempt now. If there are any restrictions in force now, return an array * of reasons why access should be blocked. If access is OK, return false. * * @return array An array of reason why access is not allowed, or an empty array * (== false) if access should be allowed. */ public function prevent_access(): array { $reasons = []; foreach ($this->rules as $rule) { $reasons = $this->accumulate_messages($reasons, $rule->prevent_access()); } return $reasons; } /** * Is a UI check is required before the user starts/continues their attempt. * * @param int|null $attemptid the id of the current attempt, if there is one, * otherwise null. * @return bool whether a check is required. */ public function is_preflight_check_required(?int $attemptid): bool { foreach ($this->rules as $rule) { if ($rule->is_preflight_check_required($attemptid)) { return true; } } return false; } /** * Build the form required to do the pre-flight checks. * @param moodle_url $url the form action URL. * @param int|null $attemptid the id of the current attempt, if there is one, * otherwise null. * @return preflight_check_form the form. */ public function get_preflight_check_form(moodle_url $url, ?int $attemptid): preflight_check_form { // This form normally wants POST submissions. However, it also needs to // accept GET submissions. Since formslib is strict, we have to detect // which case we are in, and set the form property appropriately. $method = 'post'; if (!empty($_GET['_qf__preflight_check_form'])) { $method = 'get'; } return new preflight_check_form($url->out_omit_querystring(), ['rules' => $this->rules, 'quizobj' => $this->quizobj, 'attemptid' => $attemptid, 'hidden' => $url->params()], $method); } /** * The pre-flight check has passed. This is a chance to record that fact in some way. * * @param int|null $attemptid the id of the current attempt, if there is one, * otherwise null. */ public function notify_preflight_check_passed(?int $attemptid): void { foreach ($this->rules as $rule) { $rule->notify_preflight_check_passed($attemptid); } } /** * Inform the rules that the current attempt is finished. * * This is use, for example by the password rule, to clear the flag in the session. */ public function current_attempt_finished(): void { foreach ($this->rules as $rule) { $rule->current_attempt_finished(); } } /** * Do any of the rules mean that this student will no be allowed any further attempts at this * quiz. Used, for example, to change the label by the grade displayed on the view page from * 'your current grade is' to 'your final grade is'. * * @param int $numprevattempts the number of previous attempts this user has made. * @param stdClass|false $lastattempt information about the user's last completed attempt. * @return bool true if there is no way the user will ever be allowed to attempt * this quiz again. */ public function is_finished(int $numprevattempts, $lastattempt): bool { foreach ($this->rules as $rule) { if ($rule->is_finished($numprevattempts, $lastattempt)) { return true; } } return false; } /** * Sets up the attempt (review or summary) page with any properties required * by the access rules. * * @param moodle_page $page the page object to initialise. */ public function setup_attempt_page(moodle_page $page): void { foreach ($this->rules as $rule) { $rule->setup_attempt_page($page); } } /** * Compute when the attempt must be submitted. * * @param stdClass $attempt the data from the relevant quiz_attempts row. * @return int|false the attempt close time. False if there is no limit. */ public function get_end_time(stdClass $attempt) { $timeclose = false; foreach ($this->rules as $rule) { $ruletimeclose = $rule->end_time($attempt); if ($ruletimeclose !== false && ($timeclose === false || $ruletimeclose < $timeclose)) { $timeclose = $ruletimeclose; } } return $timeclose; } /** * Compute what should be displayed to the user for time remaining in this attempt. * * @param stdClass $attempt the data from the relevant quiz_attempts row. * @param int $timenow the time to consider as 'now'. * @return int|false the number of seconds remaining for this attempt. * False if no limit should be displayed. */ public function get_time_left_display(stdClass $attempt, int $timenow) { $timeleft = false; foreach ($this->rules as $rule) { $ruletimeleft = $rule->time_left_display($attempt, $timenow); if ($ruletimeleft !== false && ($timeleft === false || $ruletimeleft < $timeleft)) { $timeleft = $ruletimeleft; } } return $timeleft; } /** * Is this quiz required to be shown in a popup window? * * @return bool true if a popup is required. */ public function attempt_must_be_in_popup(): bool { foreach ($this->rules as $rule) { if ($rule->attempt_must_be_in_popup()) { return true; } } return false; } /** * Get options required for opening the attempt in a popup window. * * @return array any options that are required for showing the attempt page * in a popup window. */ public function get_popup_options(): array { $options = []; foreach ($this->rules as $rule) { $options += $rule->get_popup_options(); } return $options; } /** * Send the user back to the quiz view page. Normally this is just a redirect, but * If we were in a secure window, we close this window, and reload the view window we came from. * * This method does not return; * * @param renderer $output the quiz renderer. * @param string $message optional message to output while redirecting. */ public function back_to_view_page(renderer $output, string $message = ''): void { // Actually return type 'never' on the previous line, once 8.1 is our minimum PHP version. if ($this->attempt_must_be_in_popup()) { echo $output->close_attempt_popup(new moodle_url($this->quizobj->view_url()), $message); die(); } else { redirect($this->quizobj->view_url(), $message); } } /** * Make some text into a link to review the quiz, if that is appropriate. * * @param stdClass $attempt the attempt object * @param mixed $nolongerused not used any more. * @param renderer $output quiz renderer instance. * @return string some HTML, the $linktext either unmodified or wrapped in a * link to the review page. */ public function make_review_link(stdClass $attempt, $nolongerused, renderer $output): string { // If the attempt is still open, don't link. if (in_array($attempt->state, [quiz_attempt::IN_PROGRESS, quiz_attempt::OVERDUE])) { return $output->no_review_message(''); } $when = quiz_attempt_state($this->quizobj->get_quiz(), $attempt); $reviewoptions = display_options::make_from_quiz( $this->quizobj->get_quiz(), $when); if (!$reviewoptions->attempt) { return $output->no_review_message($this->quizobj->cannot_review_message($when, true, $attempt->timefinish)); } else { return $output->review_link($this->quizobj->review_url($attempt->id), $this->attempt_must_be_in_popup(), $this->get_popup_options()); } } /** * Run the preflight checks using the given data in all the rules supporting them. * * @param array $data passed data for validation * @param array $files un-used, Moodle seems to not support it anymore * @param int|null $attemptid the id of the current attempt, if there is one, * otherwise null. * @return array of errors, empty array means no errors * @since Moodle 3.1 */ public function validate_preflight_check(array $data, array $files, ?int $attemptid): array { $errors = []; foreach ($this->rules as $rule) { if ($rule->is_preflight_check_required($attemptid)) { $errors = $rule->validate_preflight_check($data, $files, $errors, $attemptid); } } return $errors; } } PK9B\/classes/analytics/indicator/cognitive_depth.phpnu[. /** * Cognitive depth indicator - quiz. * * @package mod_quiz * @copyright 2017 David Monllao {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace mod_quiz\analytics\indicator; defined('MOODLE_INTERNAL') || die(); /** * Cognitive depth indicator - quiz. * * @package mod_quiz * @copyright 2017 David Monllao {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class cognitive_depth extends activity_base { /** * Returns the name. * * If there is a corresponding '_help' string this will be shown as well. * * @return \lang_string */ public static function get_name(): \lang_string { return new \lang_string('indicator:cognitivedepth', 'mod_quiz'); } public function get_indicator_type() { return self::INDICATOR_COGNITIVE; } public function get_cognitive_depth_level(\cm_info $cm) { return self::COGNITIVE_LEVEL_5; } /** * feedback_submitted_events * * @return string[] */ protected function feedback_submitted_events() { return ['\mod_quiz\event\attempt_submitted']; } /** * feedback_replied * * @param \cm_info $cm * @param int $contextid * @param int $userid * @param int $after * @return bool */ protected function feedback_replied(\cm_info $cm, $contextid, $userid, $after = false) { // No level 4. return false; } } PK9B\^zuu-classes/analytics/indicator/activity_base.phpnu[. /** * Activity base class. * * @package mod_quiz * @copyright 2017 onwards Ankit Agarwal * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace mod_quiz\analytics\indicator; defined('MOODLE_INTERNAL') || die(); /** * Activity base class. * * @package mod_quiz * @copyright 2017 onwards Ankit Agarwal * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ abstract class activity_base extends \core_analytics\local\indicator\community_of_inquiry_activity { /** * feedback_check_grades * * @return bool */ protected function feedback_check_grades() { // We need the grade to be released to the student to consider that feedback has been provided. return true; } /** * feedback_viewed_events * * @return string[] */ protected function feedback_viewed_events() { return ['\mod_quiz\event\course_module_viewed']; } /** * Returns the name of the field that controls activity availability. * * @return null|string */ protected function get_timeclose_field() { return 'timeclose'; } } PK9B\#.classes/analytics/indicator/social_breadth.phpnu[. /** * Social breadth indicator - quiz. * * @package mod_quiz * @copyright 2017 David Monllao {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace mod_quiz\analytics\indicator; defined('MOODLE_INTERNAL') || die(); /** * Social breadth indicator - quiz. * * @package mod_quiz * @copyright 2017 David Monllao {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class social_breadth extends activity_base { /** * Returns the name. * * If there is a corresponding '_help' string this will be shown as well. * * @return \lang_string */ public static function get_name(): \lang_string { return new \lang_string('indicator:socialbreadth', 'mod_quiz'); } public function get_indicator_type() { return self::INDICATOR_SOCIAL; } public function get_social_breadth_level(\cm_info $cm) { return self::SOCIAL_LEVEL_2; } } PK9B\La%4classes/adminpresets/adminpresets_review_setting.phpnu[. namespace mod_quiz\adminpresets; use ReflectionMethod; use core_adminpresets\local\setting\adminpresets_setting; /** * Admin settings class for the quiz review options. * * @package mod_quiz * @copyright 2021 Pimenko * @author Jordan Kesraoui | Sylvain Revenu | Pimenko based on David Monllaó code * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class adminpresets_review_setting extends adminpresets_setting { /** * The setting value is a sum of 'review_setting::times' */ protected function set_visiblevalue() { // Getting the masks descriptions (review_setting protected method). $reflectiontimes = new ReflectionMethod('mod_quiz\admin\review_setting', 'times'); $times = $reflectiontimes->invoke(null); $visiblevalue = ''; foreach ($times as $timemask => $namestring) { // If the value is checked. if ($this->value & $timemask) { $visiblevalue .= $namestring . ', '; } } $visiblevalue = rtrim($visiblevalue, ', '); $this->visiblevalue = $visiblevalue; } } PK9B\qs>classes/adminpresets/adminpresets_overdue_handling_setting.phpnu[. namespace mod_quiz\adminpresets; use core_adminpresets\local\setting\adminpresets_admin_setting_configselect_with_advanced; /** * Admin settings class for the quiz overdue attempt handling method. * * @package mod_quiz * @copyright 2021 Pimenko * @author Jordan Kesraoui | Sylvain Revenu | Pimenko based on David Monllaó code * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class adminpresets_overdue_handling_setting extends adminpresets_admin_setting_configselect_with_advanced { public function set_behaviors() { $this->behaviors['loadchoices'] = &$this->settingdata; } } PK9B\oll:classes/adminpresets/adminpresets_grade_method_setting.phpnu[. namespace mod_quiz\adminpresets; use core_adminpresets\local\setting\adminpresets_admin_setting_configselect_with_advanced; /** * Admin settings class for the quiz grading method. * * @package mod_quiz * @copyright 2021 Pimenko * @author Jordan Kesraoui | Sylvain Revenu | Pimenko based on David Monllaó code * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class adminpresets_grade_method_setting extends adminpresets_admin_setting_configselect_with_advanced { public function set_behaviors() { $this->behaviors['loadchoices'] = &$this->settingdata; } } PK9B\y8classes/adminpresets/adminpresets_user_image_setting.phpnu[. namespace mod_quiz\adminpresets; use core_adminpresets\local\setting\adminpresets_admin_setting_configselect_with_advanced; /** * Admin settings class for the choices for how to display the user's image. * * @package mod_quiz * @copyright 2021 Pimenko * @author Jordan Kesraoui | Sylvain Revenu | Pimenko based on David Monllaó code * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class adminpresets_user_image_setting extends adminpresets_admin_setting_configselect_with_advanced { public function set_behaviors() { $this->behaviors['loadchoices'] = &$this->settingdata; } } PK9B\ _yy>classes/adminpresets/adminpresets_browser_security_setting.phpnu[. namespace mod_quiz\adminpresets; use core_adminpresets\local\setting\adminpresets_admin_setting_configselect_with_advanced; /** * Admin settings class for the quiz browser security option. * * @package mod_quiz * @copyright 2021 Pimenko * @author Jordan Kesraoui | Sylvain Revenu | Pimenko based on David Monllaó code * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class adminpresets_browser_security_setting extends adminpresets_admin_setting_configselect_with_advanced { public function set_behaviors() { $this->behaviors['loadchoices'] = &$this->settingdata; } } PK9B\e3ssclasses/cache/overrides.phpnu[. /** * Cache data source for the quiz overrides. * * @package mod_quiz * @copyright 2021 Shamim Rezaie * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ declare(strict_types=1); namespace mod_quiz\cache; use core_cache\data_source_interface; use core_cache\definition; /** * Class quiz_overrides * * @package mod_quiz * @copyright 2021 Shamim Rezaie * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class overrides implements data_source_interface { /** @var overrides the singleton instance of this class. */ protected static $instance = null; /** * Returns an instance of the data source class that the cache can use for loading data using the other methods * specified by this interface. * * @param definition $definition * @return overrides */ public static function get_instance_for_cache(definition $definition): overrides { if (is_null(self::$instance)) { self::$instance = new overrides(); } return self::$instance; } /** * Loads the data for the key provided ready formatted for caching. * * @param string|int $key The key to load. * @return mixed What ever data should be returned, or false if it can't be loaded. * @throws \coding_exception */ public function load_for_cache($key) { global $DB; // Ignore getting data if this is a cache invalidation - {@see \core_cache\helper::purge_by_event()}. if ($key == 'lastinvalidation') { return null; } [$quizid, $ug, $ugid] = explode('_', $key); $quizid = (int) $quizid; switch ($ug) { case 'u': $userid = (int) $ugid; $override = $DB->get_record( 'quiz_overrides', ['quiz' => $quizid, 'userid' => $userid], 'timeopen, timeclose, timelimit, attempts, password' ); break; case 'g': $groupid = (int) $ugid; $override = $DB->get_record( 'quiz_overrides', ['quiz' => $quizid, 'groupid' => $groupid], 'timeopen, timeclose, timelimit, attempts, password' ); break; default: throw new \coding_exception('Invalid cache key'); } // Return null instead of false, because false will not be cached. return $override ?: null; } /** * Loads several keys for the cache. * * @param array $keys An array of keys each of which will be string|int. * @return array An array of matching data items. */ public function load_many_for_cache(array $keys) { $results = []; foreach ($keys as $key) { $results[] = $this->load_for_cache($key); } return $results; } } PK9B\ʯclasses/structure.phpnu[. namespace mod_quiz; use coding_exception; use context_module; use core\output\inplace_editable; use core_question\local\bank\version_options; use mod_quiz\event\quiz_grade_item_created; use mod_quiz\event\quiz_grade_item_deleted; use mod_quiz\event\quiz_grade_item_updated; use mod_quiz\event\slot_grade_item_updated; use mod_quiz\event\slot_mark_updated; use mod_quiz\event\slot_version_updated; use mod_quiz\question\bank\qbank_helper; use mod_quiz\question\qubaids_for_quiz; use stdClass; /** * Quiz structure class. * * The structure of the quiz. That is, which questions it is built up * from. This is used on the Edit quiz page (edit.php) and also when * starting an attempt at the quiz (startattempt.php). Once an attempt * has been started, then the attempt holds the specific set of questions * that that student should answer, and we no longer use this class. * * @package mod_quiz * @copyright 2014 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class structure { /** * Placeholder string used when a question category is missing. */ const MISSING_QUESTION_CATEGORY_PLACEHOLDER = 'missing_question_category'; /** @var quiz_settings the quiz this is the structure of. */ protected $quizobj = null; /** * @var stdClass[] the questions in this quiz. Contains the row from the questions * table, with the data from the quiz_slots table added, and also question_categories.contextid. */ protected $questions = []; /** @var stdClass[] quiz_slots.slot => the quiz_slots rows for this quiz, augmented by sectionid. */ protected $slotsinorder = []; /** * @var stdClass[] this quiz's data from the quiz_sections table. Each item has a ->lastslot field too. */ protected $sections = []; /** @var stdClass[] quiz_grade_items for this quiz indexed by id. */ protected array $gradeitems = []; /** @var bool caches the results of can_be_edited. */ protected $canbeedited = null; /** @var bool caches the results of can_add_random_question. */ protected $canaddrandom = null; /** @var array the slotids => question categories array for all slots containing a random question. */ protected $randomslotcategories = null; /** @var array the slotids => question tags array for all slots containing a random question. */ protected $randomslottags = null; /** @var array an array of question banks course_modules records indexed by their associated contextid */ protected array $questionsources = []; /** * Create an instance of this class representing an empty quiz. * * @return structure */ public static function create() { return new self(); } /** * Create an instance of this class representing the structure of a given quiz. * * @param quiz_settings $quizobj the quiz. * @return structure */ public static function create_for_quiz($quizobj) { $structure = self::create(); $structure->quizobj = $quizobj; $structure->populate_structure(); return $structure; } /** * Whether there are any questions in the quiz. * * @return bool true if there is at least one question in the quiz. */ public function has_questions() { return !empty($this->questions); } /** * Get the number of questions in the quiz. * * @return int the number of questions in the quiz. */ public function get_question_count() { return count($this->questions); } /** * Get the information about the question with this id. * * @param int $questionid The question id. * @return stdClass the data from the questions table, augmented with * question_category.contextid, and the quiz_slots data for the question in this quiz. */ public function get_question_by_id($questionid) { return $this->questions[$questionid]; } /** * Get the information about the question in a given slot. * * @param int $slotnumber the index of the slot in question. * @return stdClass the data from the questions table, augmented with * question_category.contextid, and the quiz_slots data for the question in this quiz. */ public function get_question_in_slot($slotnumber) { return $this->questions[$this->slotsinorder[$slotnumber]->questionid]; } /** * Get the name of the question in a given slot. * * @param int $slotnumber the index of the slot in question. * @return stdClass the data from the questions table, augmented with */ public function get_question_name_in_slot($slotnumber) { return $this->questions[$this->slotsinorder[$slotnumber]->name]; } /** * Get the displayed question number (or 'i') for a given slot. * * @param int $slotnumber the index of the slot in question. * @return string the question number ot display for this slot. */ public function get_displayed_number_for_slot($slotnumber) { $slot = $this->slotsinorder[$slotnumber]; return $slot->displaynumber ?? $slot->defaultnumber; } /** * Check the question has a number that could be customised. * * @param int $slotnumber * @return bool */ public function can_display_number_be_customised(int $slotnumber): bool { return $this->is_real_question($slotnumber) && !quiz_has_attempts($this->quizobj->get_quizid()); } /** * Check whether the question number is customised. * * @param int $slotid * @return bool * @todo MDL-76612 Final deprecation in Moodle 4.6 * @deprecated since 4.2. $slot->displayednumber is no longer used. If you need this, * use isset(...->displaynumber), but this method was not used. */ public function is_display_number_customised(int $slotid): bool { $slotobj = $this->get_slot_by_id($slotid); return isset($slotobj->displaynumber); } /** * Make slot display number in place editable api call. * @param int $slotid * @param \context $context * @return \core\output\inplace_editable */ public function make_slot_display_number_in_place_editable(int $slotid, \context $context): \core\output\inplace_editable { $slot = $this->get_slot_by_id($slotid); $editable = has_capability('mod/quiz:manage', $context); // Get the current value. $value = $slot->displaynumber ?? $slot->defaultnumber; $displayvalue = s($value); return new inplace_editable('mod_quiz', 'slotdisplaynumber', $slotid, $editable, $displayvalue, $value, get_string('edit_slotdisplaynumber_hint', 'mod_quiz'), get_string('edit_slotdisplaynumber_label', 'mod_quiz', $displayvalue)); } /** * Get the page a given slot is on. * * @param int $slotnumber the index of the slot in question. * @return int the page number of the page that slot is on. */ public function get_page_number_for_slot($slotnumber) { return $this->slotsinorder[$slotnumber]->page; } /** * Get the slot id of a given slot slot. * * @param int $slotnumber the index of the slot in question. * @return int the page number of the page that slot is on. */ public function get_slot_id_for_slot($slotnumber) { return $this->slotsinorder[$slotnumber]->id; } /** * Get the question type in a given slot. * * @param int $slotnumber the index of the slot in question. * @return string the question type (e.g. multichoice). */ public function get_question_type_for_slot($slotnumber) { return $this->questions[$this->slotsinorder[$slotnumber]->questionid]->qtype; } /** * Whether it would be possible, given the question types, etc. for the * question in the given slot to require that the previous question had been * answered before this one is displayed. * * @param int $slotnumber the index of the slot in question. * @return bool can this question require the previous one. */ public function can_question_depend_on_previous_slot($slotnumber) { return $slotnumber > 1 && $this->can_finish_during_the_attempt($slotnumber - 1); } /** * Whether it is possible for another question to depend on this one finishing. * Note that the answer is not exact, because of random questions, and sometimes * questions cannot be depended upon because of quiz options. * * @param int $slotnumber the index of the slot in question. * @return bool can this question finish naturally during the attempt? */ public function can_finish_during_the_attempt($slotnumber) { if ($this->quizobj->get_navigation_method() == QUIZ_NAVMETHOD_SEQ) { return false; } if ($this->slotsinorder[$slotnumber]->section->shufflequestions) { return false; } if (in_array($this->get_question_type_for_slot($slotnumber), ['random', 'missingtype'])) { return \question_engine::can_questions_finish_during_the_attempt( $this->quizobj->get_quiz()->preferredbehaviour); } if (isset($this->slotsinorder[$slotnumber]->canfinish)) { return $this->slotsinorder[$slotnumber]->canfinish; } try { $quba = \question_engine::make_questions_usage_by_activity('mod_quiz', $this->quizobj->get_context()); $tempslot = $quba->add_question(\question_bank::load_question( $this->slotsinorder[$slotnumber]->questionid)); $quba->set_preferred_behaviour($this->quizobj->get_quiz()->preferredbehaviour); $quba->start_all_questions(); $this->slotsinorder[$slotnumber]->canfinish = $quba->can_question_finish_during_attempt($tempslot); return $this->slotsinorder[$slotnumber]->canfinish; } catch (\Exception $e) { // If the question fails to start, this should not block editing. return false; } } /** * Whether it would be possible, given the question types, etc. for the * question in the given slot to require that the previous question had been * answered before this one is displayed. * * @param int $slotnumber the index of the slot in question. * @return bool can this question require the previous one. */ public function is_question_dependent_on_previous_slot($slotnumber) { return $this->slotsinorder[$slotnumber]->requireprevious; } /** * Is a particular question in this attempt a real question, or something like a description. * * @param int $slotnumber the index of the slot in question. * @return bool whether that question is a real question. */ public function is_real_question($slotnumber) { return $this->get_question_in_slot($slotnumber)->length != 0; } /** * Does the current user have '...use' capability over the question(s) in a given slot? * * * @param int $slotnumber the index of the slot in question. * @return bool true if they have the required capability. */ public function has_use_capability(int $slotnumber): bool { $slot = $this->slotsinorder[$slotnumber]; if (is_numeric($slot->questionid)) { // Non-random question. return question_has_capability_on($this->get_question_by_id($slot->questionid), 'use'); } else { // Random question. $context = \context::instance_by_id($slot->contextid); return has_capability('moodle/question:useall', $context); } } /** * Get the course id that the quiz belongs to. * * @return int the course.id for the quiz. */ public function get_courseid() { return $this->quizobj->get_courseid(); } /** * Get the course module id of the quiz. * * @return int the course_modules.id for the quiz. */ public function get_cmid() { return $this->quizobj->get_cmid(); } /** * Get the quiz context. * * @return context_module the context of the quiz that this is the structure of. */ public function get_context(): context_module { return $this->quizobj->get_context(); } /** * Get id of the quiz. * * @return int the quiz.id for the quiz. */ public function get_quizid() { return $this->quizobj->get_quizid(); } /** * Get the quiz object. * * @return stdClass the quiz settings row from the database. */ public function get_quiz() { return $this->quizobj->get_quiz(); } /** * Quizzes can only be repaginated if they have not been attempted, the * questions are not shuffled, and there are two or more questions. * * @return bool whether this quiz can be repaginated. */ public function can_be_repaginated() { return $this->can_be_edited() && $this->get_question_count() >= 2; } /** * Quizzes can only be edited if they have not been attempted. * * @return bool whether the quiz can be edited. */ public function can_be_edited() { if ($this->canbeedited === null) { $this->canbeedited = !quiz_has_attempts($this->quizobj->get_quizid()); } return $this->canbeedited; } /** * This quiz can only be edited if they have not been attempted. * Throw an exception if this is not the case. */ public function check_can_be_edited() { if (!$this->can_be_edited()) { $reportlink = quiz_attempt_summary_link_to_reports($this->get_quiz(), $this->quizobj->get_cm(), $this->quizobj->get_context()); throw new \moodle_exception('cannoteditafterattempts', 'quiz', new \moodle_url('/mod/quiz/edit.php', ['cmid' => $this->get_cmid()]), $reportlink); } } /** * How many questions are allowed per page in the quiz. * This setting controls how frequently extra page-breaks should be inserted * automatically when questions are added to the quiz. * * @return int the number of questions that should be on each page of the * quiz by default. */ public function get_questions_per_page() { return $this->quizobj->get_quiz()->questionsperpage; } /** * Get quiz slots. * * @return stdClass[] the slots in this quiz. */ public function get_slots() { return array_column($this->slotsinorder, null, 'id'); } /** * Is this slot the first one on its page? * * @param int $slotnumber the index of the slot in question. * @return bool whether this slot the first one on its page. */ public function is_first_slot_on_page($slotnumber) { if ($slotnumber == 1) { return true; } return $this->slotsinorder[$slotnumber]->page != $this->slotsinorder[$slotnumber - 1]->page; } /** * Is this slot the last one on its page? * * @param int $slotnumber the index of the slot in question. * @return bool whether this slot the last one on its page. */ public function is_last_slot_on_page($slotnumber) { if (!isset($this->slotsinorder[$slotnumber + 1])) { return true; } return $this->slotsinorder[$slotnumber]->page != $this->slotsinorder[$slotnumber + 1]->page; } /** * Is this slot the last one in its section? * * @param int $slotnumber the index of the slot in question. * @return bool whether this slot the last one on its section. */ public function is_last_slot_in_section($slotnumber) { return $slotnumber == $this->slotsinorder[$slotnumber]->section->lastslot; } /** * Is this slot the only one in its section? * * @param int $slotnumber the index of the slot in question. * @return bool whether this slot the only one on its section. */ public function is_only_slot_in_section($slotnumber) { return $this->slotsinorder[$slotnumber]->section->firstslot == $this->slotsinorder[$slotnumber]->section->lastslot; } /** * Is this slot the last one in the quiz? * * @param int $slotnumber the index of the slot in question. * @return bool whether this slot the last one in the quiz. */ public function is_last_slot_in_quiz($slotnumber) { end($this->slotsinorder); return $slotnumber == key($this->slotsinorder); } /** * Is this the first section in the quiz? * * @param stdClass $section the quiz_sections row. * @return bool whether this is first section in the quiz. */ public function is_first_section($section) { return $section->firstslot == 1; } /** * Is this the last section in the quiz? * * @param stdClass $section the quiz_sections row. * @return bool whether this is first section in the quiz. */ public function is_last_section($section) { return $section->id == end($this->sections)->id; } /** * Does this section only contain one slot? * * @param stdClass $section the quiz_sections row. * @return bool whether this section contains only one slot. */ public function is_only_one_slot_in_section($section) { return $section->firstslot == $section->lastslot; } /** * Get the final slot in the quiz. * * @return stdClass the quiz_slots for the final slot in the quiz. */ public function get_last_slot() { return end($this->slotsinorder); } /** * Get a slot by its id. Throws an exception if it is missing. * * @param int $slotid the slot id. * @return stdClass the requested quiz_slots row. */ public function get_slot_by_id($slotid) { foreach ($this->slotsinorder as $slot) { if ($slot->id == $slotid) { return $slot; } } throw new coding_exception('The slot with id ' . $slotid . ' could not be found in the quiz with id ' . $this->get_quizid() . '.'); } /** * Get a slot by its slot number. Throws an exception if it is missing. * * @param int $slotnumber The slot number * @return stdClass * @throws coding_exception */ public function get_slot_by_number($slotnumber) { if (!array_key_exists($slotnumber, $this->slotsinorder)) { throw new coding_exception('The \'slotnumber\' could not be found.'); } return $this->slotsinorder[$slotnumber]; } /** * Check whether adding a section heading is possible * * @param int $pagenumber the number of the page. * @return boolean */ public function can_add_section_heading($pagenumber) { // There is a default section heading on this page, // do not show adding new section heading in the Add menu. if ($pagenumber == 1) { return false; } // Get an array of firstslots. $firstslots = []; foreach ($this->sections as $section) { $firstslots[] = $section->firstslot; } foreach ($this->slotsinorder as $slot) { if ($slot->page == $pagenumber) { if (in_array($slot->slot, $firstslots)) { return false; } } } // Do not show the adding section heading on the last add menu. if ($pagenumber == 0) { return false; } return true; } /** * Get all the slots in a section of the quiz. * * @param int $sectionid the section id. * @return int[] slot numbers. */ public function get_slots_in_section($sectionid) { $slots = []; foreach ($this->slotsinorder as $slot) { if ($slot->section->id == $sectionid) { $slots[] = $slot->slot; } } return $slots; } /** * Get all the sections of the quiz. * * @return stdClass[] the sections in this quiz. */ public function get_sections() { return $this->sections; } /** * Get a particular section by id. * * @return stdClass the section. */ public function get_section_by_id($sectionid) { return $this->sections[$sectionid]; } /** * Get the number of questions in the quiz. * * @return int the number of questions in the quiz. */ public function get_section_count() { return count($this->sections); } /** * Get the overall quiz grade formatted for display. * * @return string the maximum grade for this quiz. */ public function formatted_quiz_grade() { return quiz_format_grade($this->get_quiz(), $this->get_quiz()->grade); } /** * Get the maximum mark for a question, formatted for display. * * @param int $slotnumber the index of the slot in question. * @return string the maximum mark for the question in this slot. */ public function formatted_question_grade($slotnumber) { return quiz_format_question_grade($this->get_quiz(), $this->slotsinorder[$slotnumber]->maxmark); } /** * Get the number of decimal places for displaying overall quiz grades or marks. * * @return int the number of decimal places. */ public function get_decimal_places_for_grades() { return $this->get_quiz()->decimalpoints; } /** * Get the number of decimal places for displaying question marks. * * @return int the number of decimal places. */ public function get_decimal_places_for_question_marks() { return quiz_get_grade_format($this->get_quiz()); } /** * Get any warnings to show at the top of the edit page. * @return string[] array of strings. */ public function get_edit_page_warnings() { $warnings = []; if (quiz_has_attempts($this->quizobj->get_quizid())) { $reviewlink = quiz_attempt_summary_link_to_reports($this->quizobj->get_quiz(), $this->quizobj->get_cm(), $this->quizobj->get_context()); $warnings[] = get_string('cannoteditafterattempts', 'quiz', $reviewlink); } return $warnings; } /** * Get the date information about the current state of the quiz. * @return string[] array of two strings. First a short summary, then a longer * explanation of the current state, e.g. for a tool-tip. */ public function get_dates_summary() { $timenow = time(); $quiz = $this->quizobj->get_quiz(); // Exact open and close dates for the tool-tip. $dates = []; if ($quiz->timeopen > 0) { if ($timenow > $quiz->timeopen) { $dates[] = get_string('quizopenedon', 'quiz', userdate($quiz->timeopen)); } else { $dates[] = get_string('quizwillopen', 'quiz', userdate($quiz->timeopen)); } } if ($quiz->timeclose > 0) { if ($timenow > $quiz->timeclose) { $dates[] = get_string('quizclosed', 'quiz', userdate($quiz->timeclose)); } else { $dates[] = get_string('quizcloseson', 'quiz', userdate($quiz->timeclose)); } } if (empty($dates)) { $dates[] = get_string('alwaysavailable', 'quiz'); } $explanation = implode(', ', $dates); // Brief summary on the page. if ($timenow < $quiz->timeopen) { $currentstatus = get_string('quizisclosedwillopen', 'quiz', userdate($quiz->timeopen, get_string('strftimedatetimeshort', 'langconfig'))); } else if ($quiz->timeclose && $timenow <= $quiz->timeclose) { $currentstatus = get_string('quizisopenwillclose', 'quiz', userdate($quiz->timeclose, get_string('strftimedatetimeshort', 'langconfig'))); } else if ($quiz->timeclose && $timenow > $quiz->timeclose) { $currentstatus = get_string('quizisclosed', 'quiz'); } else { $currentstatus = get_string('quizisopen', 'quiz'); } return [$currentstatus, $explanation]; } /** * Set up this class with the structure for a given quiz. */ protected function populate_structure() { global $DB; $this->populate_grade_items(); $slots = qbank_helper::get_question_structure($this->quizobj->get_quizid(), $this->quizobj->get_context()); $this->questions = []; $this->slotsinorder = []; foreach ($slots as $slotdata) { $this->questions[$slotdata->questionid] = $slotdata; $slot = clone($slotdata); $slot->quizid = $this->quizobj->get_quizid(); $this->slotsinorder[$slot->slot] = $slot; } // Get quiz sections in ascending order of the firstslot. $this->sections = $DB->get_records('quiz_sections', ['quizid' => $this->quizobj->get_quizid()], 'firstslot'); $this->populate_slots_with_sections(); $this->populate_question_numbers(); } /** * Load the information about the grade items for this quiz. */ protected function populate_grade_items(): void { global $DB; $this->gradeitems = $DB->get_records('quiz_grade_items', ['quizid' => $this->get_quizid()], 'sortorder'); } /** * Fill in the section ids for each slot. */ public function populate_slots_with_sections() { $sections = array_values($this->sections); foreach ($sections as $i => $section) { if (isset($sections[$i + 1])) { $section->lastslot = $sections[$i + 1]->firstslot - 1; } else { $section->lastslot = count($this->slotsinorder); } for ($slot = $section->firstslot; $slot <= $section->lastslot; $slot += 1) { $this->slotsinorder[$slot]->section = $section; } } } /** * Number the questions. */ protected function populate_question_numbers() { $number = 1; foreach ($this->slotsinorder as $slot) { $question = $this->questions[$slot->questionid]; if ($question->length == 0) { $slot->displaynumber = null; $slot->defaultnumber = get_string('infoshort', 'quiz'); } else { $slot->defaultnumber = $number; } if ($slot->displaynumber === '') { $slot->displaynumber = null; } $number += $question->length; } } /** * Get the version options to show on the 'Questions' page for a particular question. * * @param int $slotnumber which slot to get the choices for. * @return stdClass[] other versions of this question. Each object has fields versionid, * version and selected. Array is returned most recent version first. */ public function get_version_choices_for_slot(int $slotnumber): array { $slot = $this->get_slot_by_number($slotnumber); // Get all the versions which exist. $versions = version_options::get_version_menu_options($slot->questionid); $versioninfo = []; // Loop through them and set which one is selected. foreach ($versions as $versionnumber => $version) { $versioninfo[] = (object)[ 'version' => $versionnumber, 'versionvalue' => $version, 'selected' => ($versionnumber == $slot->requestedversion), ]; } return $versioninfo; } /** * Move a slot from its current location to a new location. * * After calling this method, this class will be in an invalid state, and * should be discarded if you want to manipulate the structure further. * * @param int $idmove id of slot to be moved * @param int $idmoveafter id of slot to come before slot being moved * @param int $page new page number of slot being moved */ public function move_slot($idmove, $idmoveafter, $page) { global $DB; $this->check_can_be_edited(); $movingslot = $this->get_slot_by_id($idmove); if (empty($movingslot)) { throw new \moodle_exception('Bad slot ID ' . $idmove); } $movingslotnumber = (int) $movingslot->slot; // Empty target slot means move slot to first. if (empty($idmoveafter)) { $moveafterslotnumber = 0; } else { $moveafterslotnumber = (int) $this->get_slot_by_id($idmoveafter)->slot; } // If the action came in as moving a slot to itself, normalise this to // moving the slot to after the previous slot. if ($moveafterslotnumber == $movingslotnumber) { $moveafterslotnumber = $moveafterslotnumber - 1; } $followingslotnumber = $moveafterslotnumber + 1; // Prevent checking against non-existence slot when already at the last slot. if ($followingslotnumber == $movingslotnumber && !$this->is_last_slot_in_quiz($followingslotnumber)) { $followingslotnumber += 1; } // Check the target page number is OK. if ($page == 0 || $page === '') { $page = 1; } if (($moveafterslotnumber > 0 && $page < $this->get_page_number_for_slot($moveafterslotnumber)) || $page < 1) { throw new coding_exception('The target page number is too small.'); } else if (!$this->is_last_slot_in_quiz($moveafterslotnumber) && $page > $this->get_page_number_for_slot($followingslotnumber)) { throw new coding_exception('The target page number is too large.'); } // Work out how things are being moved. $slotreorder = []; if ($moveafterslotnumber > $movingslotnumber) { // Moving down. $slotreorder[$movingslotnumber] = $moveafterslotnumber; for ($i = $movingslotnumber; $i < $moveafterslotnumber; $i++) { $slotreorder[$i + 1] = $i; } $headingmoveafter = $movingslotnumber; if ($this->is_last_slot_in_quiz($moveafterslotnumber) || $page == $this->get_page_number_for_slot($moveafterslotnumber + 1)) { // We are moving to the start of a section, so that heading needs // to be included in the ones that move up. $headingmovebefore = $moveafterslotnumber + 1; } else { $headingmovebefore = $moveafterslotnumber; } $headingmovedirection = -1; } else if ($moveafterslotnumber < $movingslotnumber - 1) { // Moving up. $slotreorder[$movingslotnumber] = $moveafterslotnumber + 1; for ($i = $moveafterslotnumber + 1; $i < $movingslotnumber; $i++) { $slotreorder[$i] = $i + 1; } if ($page == $this->get_page_number_for_slot($moveafterslotnumber + 1)) { // Moving to the start of a section, don't move that section. $headingmoveafter = $moveafterslotnumber + 1; } else { // Moving tot the end of the previous section, so move the heading down too. $headingmoveafter = $moveafterslotnumber; } $headingmovebefore = $movingslotnumber + 1; $headingmovedirection = 1; } else { // Staying in the same place, but possibly changing page/section. if ($page > $movingslot->page) { $headingmoveafter = $movingslotnumber; $headingmovebefore = $movingslotnumber + 2; $headingmovedirection = -1; } else if ($page < $movingslot->page) { $headingmoveafter = $movingslotnumber - 1; $headingmovebefore = $movingslotnumber + 1; $headingmovedirection = 1; } else { return; // Nothing to do. } } if ($this->is_only_slot_in_section($movingslotnumber)) { throw new coding_exception('You cannot remove the last slot in a section.'); } $trans = $DB->start_delegated_transaction(); // Slot has moved record new order. if ($slotreorder) { update_field_with_unique_index('quiz_slots', 'slot', $slotreorder, ['quizid' => $this->get_quizid()]); } // Page has changed. Record it. if ($movingslot->page != $page) { $DB->set_field('quiz_slots', 'page', $page, ['id' => $movingslot->id]); } // Update section fist slots. quiz_update_section_firstslots($this->get_quizid(), $headingmovedirection, $headingmoveafter, $headingmovebefore); // If any pages are now empty, remove them. $emptypages = $DB->get_fieldset_sql(" SELECT DISTINCT page - 1 FROM {quiz_slots} slot WHERE quizid = ? AND page > 1 AND NOT EXISTS (SELECT 1 FROM {quiz_slots} WHERE quizid = ? AND page = slot.page - 1) ORDER BY page - 1 DESC ", [$this->get_quizid(), $this->get_quizid()]); foreach ($emptypages as $emptypage) { $DB->execute(" UPDATE {quiz_slots} SET page = page - 1 WHERE quizid = ? AND page > ? ", [$this->get_quizid(), $emptypage]); } $trans->allow_commit(); // Log slot moved event. $event = \mod_quiz\event\slot_moved::create([ 'context' => $this->quizobj->get_context(), 'objectid' => $idmove, 'other' => [ 'quizid' => $this->quizobj->get_quizid(), 'previousslotnumber' => $movingslotnumber, 'afterslotnumber' => $moveafterslotnumber, 'page' => $page ] ]); $event->trigger(); } /** * Refresh page numbering of quiz slots. * @param stdClass[] $slots (optional) array of slot objects. * @return stdClass[] array of slot objects. */ public function refresh_page_numbers($slots = []) { global $DB; // Get slots ordered by page then slot. if (!count($slots)) { $slots = $DB->get_records('quiz_slots', ['quizid' => $this->get_quizid()], 'slot, page'); } // Loop slots. Start the page number at 1 and increment as required. $pagenumbers = ['new' => 0, 'old' => 0]; foreach ($slots as $slot) { if ($slot->page !== $pagenumbers['old']) { $pagenumbers['old'] = $slot->page; ++$pagenumbers['new']; } if ($pagenumbers['new'] == $slot->page) { continue; } $slot->page = $pagenumbers['new']; } return $slots; } /** * Refresh page numbering of quiz slots and save to the database. * * @return stdClass[] array of slot objects. */ public function refresh_page_numbers_and_update_db() { global $DB; $this->check_can_be_edited(); $slots = $this->refresh_page_numbers(); // Record new page order. foreach ($slots as $slot) { $DB->set_field('quiz_slots', 'page', $slot->page, ['id' => $slot->id]); } return $slots; } /** * Remove a slot from a quiz. * * @param int $slotnumber The number of the slot to be deleted. * @throws coding_exception */ public function remove_slot($slotnumber) { global $DB; $this->check_can_be_edited(); if ($this->is_only_slot_in_section($slotnumber) && $this->get_section_count() > 1) { throw new coding_exception('You cannot remove the last slot in a section.'); } $slot = $DB->get_record('quiz_slots', ['quizid' => $this->get_quizid(), 'slot' => $slotnumber]); if (!$slot) { return; } $maxslot = $DB->get_field_sql('SELECT MAX(slot) FROM {quiz_slots} WHERE quizid = ?', [$this->get_quizid()]); $contextid = $this->get_context()->id; $trans = $DB->start_delegated_transaction(); // Delete the reference if it is a question. $questionreference = $DB->get_record('question_references', [ 'usingcontextid' => $contextid, 'component' => 'mod_quiz', 'questionarea' => 'slot', 'itemid' => $slot->id, ]); if ($questionreference) { $DB->delete_records('question_references', ['id' => $questionreference->id]); } // Delete the set reference if it is a random question. $questionsetreference = $DB->get_record('question_set_references', [ 'usingcontextid' => $contextid, 'component' => 'mod_quiz', 'questionarea' => 'slot', 'itemid' => $slot->id, ]); if ($questionsetreference) { $DB->delete_records('question_set_references', ['id' => $questionsetreference->id, 'component' => 'mod_quiz', 'questionarea' => 'slot']); } $DB->delete_records('quiz_slots', ['id' => $slot->id]); for ($i = $slot->slot + 1; $i <= $maxslot; $i++) { $DB->set_field('quiz_slots', 'slot', $i - 1, ['quizid' => $this->get_quizid(), 'slot' => $i]); $this->slotsinorder[$i]->slot = $i - 1; $this->slotsinorder[$i - 1] = $this->slotsinorder[$i]; unset($this->slotsinorder[$i]); } quiz_update_section_firstslots($this->get_quizid(), -1, $slotnumber); foreach ($this->sections as $key => $section) { if ($section->firstslot > $slotnumber) { $this->sections[$key]->firstslot--; } } $this->populate_slots_with_sections(); $this->populate_question_numbers(); $this->unset_question($slot->id); $this->refresh_page_numbers_and_update_db(); $trans->allow_commit(); // Log slot deleted event. $event = \mod_quiz\event\slot_deleted::create([ 'context' => $this->quizobj->get_context(), 'objectid' => $slot->id, 'other' => [ 'quizid' => $this->get_quizid(), 'slotnumber' => $slotnumber, ] ]); $event->trigger(); } /** * Unset the question object after deletion. * * @param int $slotid */ public function unset_question($slotid) { foreach ($this->questions as $key => $question) { if ($question->slotid === $slotid) { unset($this->questions[$key]); } } } /** * Change the max mark for a slot. * * Save changes to the question grades in the quiz_slots table and any * corresponding question_attempts. * * It does not update 'sumgrades' in the quiz table. * * @param stdClass $slot row from the quiz_slots table. * @param float $maxmark the new maxmark. * @return bool true if the new grade is different from the old one. */ public function update_slot_maxmark($slot, $maxmark) { global $DB; if (abs($maxmark - $slot->maxmark) < 1e-7) { // Grade has not changed. Nothing to do. return false; } $transaction = $DB->start_delegated_transaction(); $DB->set_field('quiz_slots', 'maxmark', $maxmark, ['id' => $slot->id]); \question_engine::set_max_mark_in_attempts(new qubaids_for_quiz($slot->quizid), $slot->slot, $maxmark); // Log slot mark updated event. // We use $num + 0 as a trick to remove the useless 0 digits from decimals. $event = slot_mark_updated::create([ 'context' => $this->quizobj->get_context(), 'objectid' => $slot->id, 'other' => [ 'quizid' => $this->get_quizid(), 'previousmaxmark' => $slot->maxmark + 0, 'newmaxmark' => $maxmark + 0 ] ]); $event->trigger(); $this->slotsinorder[$slot->slot]->maxmark = $maxmark; $transaction->allow_commit(); return true; } /** * Update the question version for a given slot, if necessary. * * @param int $id ID of row from the quiz_slots table. * @param int|null $newversion The new question version for the slot. * A null value means 'Always latest'. * @return bool True if the version was updated, false if no update was required. * @throws coding_exception If the specified version does not exist. */ public function update_slot_version(int $id, ?int $newversion): bool { global $DB; $slot = $this->get_slot_by_id($id); $context = $this->quizobj->get_context(); $refparams = ['usingcontextid' => $context->id, 'component' => 'mod_quiz', 'questionarea' => 'slot', 'itemid' => $slot->id]; $reference = $DB->get_record('question_references', $refparams, '*', MUST_EXIST); $oldversion = is_null($reference->version) ? null : (int) $reference->version; $reference->version = $newversion === 0 ? null : $newversion; $existsparams = ['questionbankentryid' => $reference->questionbankentryid, 'version' => $newversion]; $versionexists = $DB->record_exists('question_versions', $existsparams); // We are attempting to switch to an existing version. // Verify that the version we want to switch to exists. if (!is_null($newversion) && !$versionexists) { throw new coding_exception( 'Version: ' . $newversion . ' ' . 'does not exist for question bank entry: ' . $reference->questionbankentryid ); } if ($newversion === $oldversion) { return false; } $transaction = $DB->start_delegated_transaction(); $DB->update_record('question_references', $reference); slot_version_updated::create([ 'context' => $this->quizobj->get_context(), 'objectid' => $slot->id, 'other' => [ 'quizid' => $this->get_quizid(), 'previousversion' => $oldversion, 'newversion' => $reference->version, ], ])->trigger(); $transaction->allow_commit(); return true; } /** * Change which grade this slot contributes to, for quizzes with multiple grades. * * It does not update 'sumgrades' in the quiz table. If this method returns true, * it will be necessary to recompute all the quiz grades. * * @param stdClass $slot row from the quiz_slots table. * @param int|null $gradeitemid id of the grade item this slot should contribute to. 0 or null means none. * @return bool true if the new $gradeitemid is different from the previous one. */ public function update_slot_grade_item(stdClass $slot, ?int $gradeitemid): bool { global $DB; if ($gradeitemid === 0) { $gradeitemid = null; } if ($gradeitemid !== null && !$this->is_real_question($slot->slot)) { throw new coding_exception('Cannot set a grade item for a question that is ungraded.'); } if ($slot->quizgradeitemid !== null) { // Object $slot likely comes from the database, which means int may be // represented as a string, which breaks the next test, so fix up. $slot->quizgradeitemid = (int) $slot->quizgradeitemid; } if ($gradeitemid === $slot->quizgradeitemid) { // Grade has not changed. Nothing to do. return false; } $transaction = $DB->start_delegated_transaction(); $DB->set_field('quiz_slots', 'quizgradeitemid', $gradeitemid, ['id' => $slot->id]); // Log slot mark updated event. slot_grade_item_updated::create([ 'context' => $this->quizobj->get_context(), 'objectid' => $slot->id, 'other' => [ 'quizid' => $this->get_quizid(), 'previousgradeitem' => $slot->quizgradeitemid, 'newgradeitem' => $gradeitemid, ], ])->trigger(); $this->slotsinorder[$slot->slot]->quizgradeitemid = $gradeitemid; $transaction->allow_commit(); return true; } /** * Set whether the question in a particular slot requires the previous one. * @param int $slotid id of slot. * @param bool $requireprevious if true, set this question to require the previous one. */ public function update_question_dependency($slotid, $requireprevious) { global $DB; $DB->set_field('quiz_slots', 'requireprevious', $requireprevious, ['id' => $slotid]); // Log slot require previous event. $event = \mod_quiz\event\slot_requireprevious_updated::create([ 'context' => $this->quizobj->get_context(), 'objectid' => $slotid, 'other' => [ 'quizid' => $this->get_quizid(), 'requireprevious' => $requireprevious ? 1 : 0 ] ]); $event->trigger(); } /** * Update the question display number when is set as customised display number or empy string. * When the field displaynumber is set to empty string, the automated numbering is used. * Log the updated displatnumber field. * * @param int $slotid id of slot. * @param string $displaynumber set to customised string as question number or empty string fo autonumbering. */ public function update_slot_display_number(int $slotid, string $displaynumber): void { global $DB; $DB->set_field('quiz_slots', 'displaynumber', $displaynumber, ['id' => $slotid]); $this->populate_structure(); // Log slot displaynumber event (customised question number). $event = \mod_quiz\event\slot_displaynumber_updated::create([ 'context' => $this->quizobj->get_context(), 'objectid' => $slotid, 'other' => [ 'quizid' => $this->get_quizid(), 'displaynumber' => $displaynumber ] ]); $event->trigger(); } /** * Add/Remove a pagebreak. * * Save changes to the slot page relationship in the quiz_slots table and reorders the paging * for subsequent slots. * * @param int $slotid id of slot which we will add/remove the page break before. * @param int $type repaginate::LINK or repaginate::UNLINK. * @return stdClass[] array of slot objects. */ public function update_page_break($slotid, $type) { global $DB; $this->check_can_be_edited(); $quizslots = $DB->get_records('quiz_slots', ['quizid' => $this->get_quizid()], 'slot'); $repaginate = new repaginate($this->get_quizid(), $quizslots); $repaginate->repaginate_slots($quizslots[$slotid]->slot, $type); $slots = $this->refresh_page_numbers_and_update_db(); if ($type == repaginate::LINK) { // Log page break created event. $event = \mod_quiz\event\page_break_deleted::create([ 'context' => $this->quizobj->get_context(), 'objectid' => $slotid, 'other' => [ 'quizid' => $this->get_quizid(), 'slotnumber' => $quizslots[$slotid]->slot ] ]); $event->trigger(); } else { // Log page deleted created event. $event = \mod_quiz\event\page_break_created::create([ 'context' => $this->quizobj->get_context(), 'objectid' => $slotid, 'other' => [ 'quizid' => $this->get_quizid(), 'slotnumber' => $quizslots[$slotid]->slot ] ]); $event->trigger(); } return $slots; } /** * Add a section heading on a given page and return the sectionid * @param int $pagenumber the number of the page where the section heading begins. * @param string|null $heading the heading to add. If not given, a default is used. */ public function add_section_heading($pagenumber, $heading = null) { global $DB; $section = new stdClass(); if ($heading !== null) { $section->heading = $heading; } else { $section->heading = get_string('newsectionheading', 'quiz'); } $section->quizid = $this->get_quizid(); $slotsonpage = $DB->get_records('quiz_slots', ['quizid' => $this->get_quizid(), 'page' => $pagenumber], 'slot DESC'); $firstslot = end($slotsonpage); $section->firstslot = $firstslot->slot; $section->shufflequestions = 0; $sectionid = $DB->insert_record('quiz_sections', $section); // Log section break created event. $event = \mod_quiz\event\section_break_created::create([ 'context' => $this->quizobj->get_context(), 'objectid' => $sectionid, 'other' => [ 'quizid' => $this->get_quizid(), 'firstslotnumber' => $firstslot->slot, 'firstslotid' => $firstslot->id, 'title' => $section->heading, ] ]); $event->trigger(); return $sectionid; } /** * Change the heading for a section. * @param int $id the id of the section to change. * @param string $newheading the new heading for this section. */ public function set_section_heading($id, $newheading) { global $DB; $section = $DB->get_record('quiz_sections', ['id' => $id], '*', MUST_EXIST); $section->heading = $newheading; $DB->update_record('quiz_sections', $section); // Log section title updated event. $firstslot = $DB->get_record('quiz_slots', ['quizid' => $this->get_quizid(), 'slot' => $section->firstslot]); $event = \mod_quiz\event\section_title_updated::create([ 'context' => $this->quizobj->get_context(), 'objectid' => $id, 'other' => [ 'quizid' => $this->get_quizid(), 'firstslotid' => $firstslot ? $firstslot->id : null, 'firstslotnumber' => $firstslot ? $firstslot->slot : null, 'newtitle' => $newheading ] ]); $event->trigger(); } /** * Change the shuffle setting for a section. * @param int $id the id of the section to change. * @param bool $shuffle whether this section should be shuffled. */ public function set_section_shuffle($id, $shuffle) { global $DB; $section = $DB->get_record('quiz_sections', ['id' => $id], '*', MUST_EXIST); $section->shufflequestions = $shuffle; $DB->update_record('quiz_sections', $section); // Log section shuffle updated event. $event = \mod_quiz\event\section_shuffle_updated::create([ 'context' => $this->quizobj->get_context(), 'objectid' => $id, 'other' => [ 'quizid' => $this->get_quizid(), 'firstslotnumber' => $section->firstslot, 'shuffle' => $shuffle ] ]); $event->trigger(); } /** * Remove the section heading with the given id * @param int $sectionid the section to remove. */ public function remove_section_heading($sectionid) { global $DB; $section = $DB->get_record('quiz_sections', ['id' => $sectionid], '*', MUST_EXIST); if ($section->firstslot == 1) { throw new coding_exception('Cannot remove the first section in a quiz.'); } $DB->delete_records('quiz_sections', ['id' => $sectionid]); // Log page deleted created event. $firstslot = $DB->get_record('quiz_slots', ['quizid' => $this->get_quizid(), 'slot' => $section->firstslot]); $event = \mod_quiz\event\section_break_deleted::create([ 'context' => $this->quizobj->get_context(), 'objectid' => $sectionid, 'other' => [ 'quizid' => $this->get_quizid(), 'firstslotid' => $firstslot->id, 'firstslotnumber' => $firstslot->slot ] ]); $event->trigger(); } /** * Whether the current user can add random questions to the quiz or not. * It is only possible to add a random question if the user has the moodle/question:useall capability * on at least one of the contexts related to the one where we are currently editing questions. * * @return bool */ public function can_add_random_questions() { if ($this->canaddrandom === null) { $quizcontext = $this->quizobj->get_context(); $relatedcontexts = new \core_question\local\bank\question_edit_contexts($quizcontext); $usablecontexts = $relatedcontexts->having_cap('moodle/question:useall'); $this->canaddrandom = !empty($usablecontexts); } return $this->canaddrandom; } /** * Get the grade items defined for this quiz. * * @return stdClass[] quiz_grade_item rows, indexed by id. */ public function get_grade_items(): array { return $this->gradeitems; } /** * Check the grade item with the given id belongs to this quiz. * * @param int $gradeitemid id of a quiz grade item. * @throws coding_exception if the grade item does not belong to this quiz. */ public function verify_grade_item_is_ours(int $gradeitemid): void { if (!array_key_exists($gradeitemid, $this->gradeitems)) { throw new coding_exception('Grade item ' . $gradeitemid . ' does not belong to quiz ' . $this->get_quizid()); } } /** * Is a particular quiz grade item used by any slots? * * @param int $gradeitemid id of a quiz grade item belonging to this quiz. * @return bool true if it is used. */ public function is_grade_item_used(int $gradeitemid): bool { $this->verify_grade_item_is_ours($gradeitemid); foreach ($this->slotsinorder as $slot) { if ($slot->quizgradeitemid == $gradeitemid) { return true; } } return false; } /** * Get the total of marks of all questions assigned to this grade item, formatted for display. * * @param int $gradeitemid id of a quiz grade item belonging to this quiz. * @return string total of marks of all questions assigned to this grade item. */ public function formatted_grade_item_sum_marks(int $gradeitemid): string { $this->verify_grade_item_is_ours($gradeitemid); $summarks = 0; foreach ($this->slotsinorder as $slot) { if ($slot->quizgradeitemid == $gradeitemid) { $summarks += $slot->maxmark; } } return quiz_format_grade($this->get_quiz(), $summarks); } /** * Create a grade item. * * The new grade item is added at the end of the order. * * @param stdClass $gradeitemdata must have property name - updated with the inserted data (sortorder and id). */ public function create_grade_item(stdClass $gradeitemdata): void { global $DB; // Add to the end of the sort order. $gradeitemdata->sortorder = $DB->get_field('quiz_grade_items', 'COALESCE(MAX(sortorder) + 1, 1)', ['quizid' => $this->get_quizid()]); // If name is blank, supply a default. if ((string) $gradeitemdata->name === '') { $count = 0; do { $count += 1; $gradeitemdata->name = get_string('gradeitemdefaultname', 'quiz', $count); } while ($DB->record_exists('quiz_grade_items', ['quizid' => $this->get_quizid(), 'name' => $gradeitemdata->name])); } $transaction = $DB->start_delegated_transaction(); // Create the grade item. $gradeitemdata->id = $DB->insert_record('quiz_grade_items', $gradeitemdata); $this->gradeitems[$gradeitemdata->id] = $DB->get_record( 'quiz_grade_items', ['id' => $gradeitemdata->id]); // Log. quiz_grade_item_created::create([ 'context' => $this->quizobj->get_context(), 'objectid' => $gradeitemdata->id, 'other' => [ 'quizid' => $this->get_quizid(), ], ])->trigger(); $transaction->allow_commit(); } /** * Update a grade item. * * @param stdClass $gradeitemdata must have properties id and name. */ public function update_grade_item(stdClass $gradeitemdata): void { global $DB; $this->verify_grade_item_is_ours($gradeitemdata->id); $transaction = $DB->start_delegated_transaction(); // Update the grade item. $DB->update_record('quiz_grade_items', $gradeitemdata); $this->gradeitems[$gradeitemdata->id] = $DB->get_record( 'quiz_grade_items', ['id' => $gradeitemdata->id]); // Log. quiz_grade_item_updated::create([ 'context' => $this->quizobj->get_context(), 'objectid' => $gradeitemdata->id, 'other' => [ 'quizid' => $this->get_quizid(), ], ])->trigger(); $transaction->allow_commit(); } /** * Delete a grade item (only if it is not used). * * @param int $gradeitemid id of the grade item to delete. Must belong to this quiz. */ public function delete_grade_item(int $gradeitemid): void { global $DB; if ($this->is_grade_item_used($gradeitemid)) { throw new coding_exception('Cannot delete a quiz grade item which is used.'); } $transaction = $DB->start_delegated_transaction(); $DB->delete_records('quiz_grade_items', ['id' => $gradeitemid]); unset($this->gradeitems[$gradeitemid]); // Log. quiz_grade_item_deleted::create([ 'context' => $this->quizobj->get_context(), 'objectid' => $gradeitemid, 'other' => [ 'quizid' => $this->get_quizid(), ], ])->trigger(); $transaction->allow_commit(); } /** * @deprecated since Moodle 4.0 MDL-71573 */ public function get_slot_tags_for_slot_id() { throw new \coding_exception(__FUNCTION__ . '() has been removed.'); } /** * Add a random question to the quiz at a given point. * * @param int $addonpage the page on which to add the question. * @param int $number the number of random questions to add. * @param array $filtercondition the filter condition. Must contain at least a category filter. */ public function add_random_questions(int $addonpage, int $number, array $filtercondition): void { global $DB; if (!isset($filtercondition['filter']['category'])) { throw new \invalid_parameter_exception('$filtercondition must contain at least a category filter.'); } $categoryid = $filtercondition['filter']['category']['values'][0]; $category = $DB->get_record('question_categories', ['id' => $categoryid]); if (!$category) { new \moodle_exception('invalidcategoryid'); } $catcontext = \context::instance_by_id($category->contextid); require_capability('moodle/question:useall', $catcontext); // Create the selected number of random questions. for ($i = 0; $i < $number; $i++) { // Slot data. $randomslotdata = new stdClass(); $randomslotdata->quizid = $this->get_quizid(); $randomslotdata->usingcontextid = context_module::instance($this->get_cmid())->id; $randomslotdata->questionscontextid = $category->contextid; $randomslotdata->maxmark = 1; $randomslot = new \mod_quiz\local\structure\slot_random($randomslotdata); $randomslot->set_quiz($this->get_quiz()); $randomslot->set_filter_condition(json_encode($filtercondition)); $randomslot->insert($addonpage); } } /** * Get a human-readable description of a random slot. * * @param int $slotid id of slot. * @return string that can be used to display the random slot. */ public function describe_random_slot(int $slotid): string { $this->ensure_random_slot_info_loaded(); if (!isset($this->randomslotcategories[$slotid])) { throw new coding_exception('Called describe_random_slot on slot id ' . $slotid . ' which is not a random slot.'); } // Build the random question name with categories and tags information and return. $a = new stdClass(); $a->category = $this->randomslotcategories[$slotid]; $stringid = 'randomqnamecat'; if (!empty($this->randomslottags[$slotid])) { $a->tags = $this->randomslottags[$slotid]; $stringid = 'randomqnamecattags'; } return shorten_text(get_string($stringid, 'quiz', $a), 255); } /** * Ensure that {@see load_random_slot_info()} has been called, so the data is available. */ protected function ensure_random_slot_info_loaded(): void { if ($this->randomslotcategories == null) { $this->load_random_slot_info(); } } /** * Load information about the question categories and tags for all random slots, */ protected function load_random_slot_info(): void { global $DB; // Find the random slots. $allslots = $this->get_slots(); foreach ($allslots as $key => $slot) { if ($slot->qtype != 'random') { unset($allslots[$key]); } } if (empty($allslots)) { // No random slots. Nothing to do. $this->randomslotcategories = []; $this->randomslottags = []; return; } // Loop over all random slots to build arrays of the data we will need. $tagids = []; $questioncategoriesids = []; // An associative array of slotid. Example structure: // ['cat' => [values => catid, 'includesubcategories' => true, 'tag' => [tagid, tagid, ...]]. $randomcategoriesandtags = []; foreach ($allslots as $slotid => $slot) { foreach ($slot->filtercondition as $name => $value) { if ($name !== 'filter') { continue; } // Parse the filter condition. foreach ($value as $filteroption => $filtervalue) { if ($filteroption === 'category') { $randomcategoriesandtags[$slotid]['cat']['values'] = $questioncategoriesids[] = $filtervalue['values'][0]; $randomcategoriesandtags[$slotid]['cat']['includesubcategories'] = $filtervalue['filteroptions']['includesubcategories'] ?? false; } if ($filteroption === 'qtagids') { foreach ($filtervalue as $qtagidsoption => $qtagidsvalue) { if ($qtagidsoption !== 'values') { continue; } foreach ($qtagidsvalue as $qtagidsvaluevalue) { $randomcategoriesandtags[$slotid]['tag'][] = $qtagidsvaluevalue; $tagids[] = $qtagidsvaluevalue; } } } } } } // Get names for all tags into a tagid => name array. $tags = \core_tag_tag::get_bulk($tagids, 'id, rawname'); $tagnames = array_map(fn($tag) => $tag->get_display_name(), $tags); // Get names for all question categories. $categories = $DB->get_records_list('question_categories', 'id', $questioncategoriesids, 'id', 'id, name, contextid, parent'); // Now, put the data required for each slot into $this->randomslotcategories and $this->randomslottags. foreach ($randomcategoriesandtags as $slotid => $catandtags) { $qcategoryid = $catandtags['cat']['values']; if (!array_key_exists($qcategoryid, $categories)) { $this->randomslotcategories[$slotid] = self::MISSING_QUESTION_CATEGORY_PLACEHOLDER; continue; } $qcategory = $categories[$qcategoryid]; $includesubcategories = $catandtags['cat']['includesubcategories']; $this->randomslotcategories[$slotid] = $this->get_used_category_description($qcategory, $includesubcategories); if (isset($catandtags['tag'])) { $slottagnames = []; foreach ($catandtags['tag'] as $tagid) { $slottagnames[] = $tagnames[$tagid]; } $this->randomslottags[$slotid] = implode(', ', $slottagnames); } } } /** * Returns a description of the used question category, taking into account the context and whether subcategories are * included. * * @param stdClass $qcategory The question category object containing category details. * @param bool $includesubcategories Whether subcategories are included. * @return string The generated description based on the used category. * @throws coding_exception If the context level is unsupported. */ private function get_used_category_description(stdClass $qcategory, bool $includesubcategories): string { $context = \context::instance_by_id($qcategory->contextid); if ($context->contextlevel != CONTEXT_MODULE) { throw new coding_exception('Unsupported context.'); } if ($qcategory->name === 'top') { // This is a "top" question category. if (!$includesubcategories) { // Question categories labeled as "top" cannot directly contain questions. If the subcategories that may // hold questions are excluded, the generated random questions will be invalid. Thus, return a description // that informs the user about the issues associated with these types of generated random questions. return get_string('randomfaultynosubcat', 'mod_quiz'); } return get_string('randommodulewithsubcat', 'mod_quiz'); } // Otherwise, return the description of the used standard question category, also indicating whether subcategories // are included. return $includesubcategories ? get_string('randomcatwithsubcat', 'mod_quiz', $qcategory->name) : $qcategory->name; } /** * Populate question_sources with cm records for later reference. * * @return void */ private function populate_question_sources(): void { global $DB; $contextids = array_map(fn($question) => $question->contextid, $this->questions); [$insql, $inparams] = $DB->get_in_or_equal(array_unique($contextids)); $sql = " SELECT c.id as contextid, cm.id, cm.course FROM {context} c JOIN {course_modules} cm ON cm.id = c.instanceid AND c.contextlevel = ? WHERE c.id {$insql} "; $params = array_merge([context_module::LEVEL], $inparams); $this->questionsources = $DB->get_records_sql($sql, $params); } /** * Get data on the question bank being used by the question in the slot. * * @param int $slot slot number * @return stdClass|null */ public function get_source_bank(int $slot): ?stdClass { $questionid = $this->slotsinorder[$slot]->questionid; $this->questionsources[$this->questions[$questionid]->contextid] ?? $this->populate_question_sources(); // This shouldn't happen as all categories belong to a module context level but let's account for it. if (empty($this->questionsources[$this->questions[$questionid]->contextid])) { return null; } $cminfo = \cm_info::create($this->questionsources[$this->questions[$questionid]->contextid]); return (object) [ 'cminfo' => $cminfo, 'issharedbank' => plugin_supports('mod', $cminfo->modname, FEATURE_PUBLISHES_QUESTIONS, false), ]; } } PK9B\D ,templates/modal_add_random_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 mod_quiz/modal_add_random_question Modal for adding a random question to a quiz activity. Classes required for JS: * none Data attributes required for JS: * none Context variables required for this template: * title A cleaned string (use clean_text()) to display. * body HTML content for the boday Example context (json): { "title": "Example modal", "body": "Some example content for the body" } }} {{< core/modal }} {{$body}}
{{> core/loading }}
{{#hidden}}
{{/hidden}}
{{/body}} {{/ core/modal }} PK9B\MҪ #templates/list_of_attempts.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 mod_quiz/list_of_attempts This template renders summary information about a list of quiz attempts. The structure for each attempt should be what is required by mod_quiz/attempt_summary_information. Example context (json): { "hasattempts": true, "attempts": [ { "name": "Attempt 1", "reviewlink": "Review", "summarydata": { "hasitems": true, "items": [ { "title": "It is me!", "content": "Sam Student" }, {"title": "Status", "content": "Finished"}, {"title": "Started", "content": "Thursday, 23 November 2023, 9:29 AM"}, {"title": "Completed", "content": "Thursday, 23 November 2023, 9:32 AM"}, {"title": "Duration", "content": "3 minutes"}, {"title": "Grade", "content": "Not yet graded"} ] } } ] } }} {{#hasattempts}}

{{# str}}summaryofattempts, quiz{{/str}}

    {{#attempts}}
  • {{name}}

    {{#summarydata}} {{> mod_quiz/attempt_summary_information}} {{/summarydata}}
    {{{reviewlink}}}
  • {{/attempts}}
{{/hasattempts}} PK9B\#templates/edit_nav_actions.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 mod_quiz/edit_nav_actions Actions bar at the top of the various quiz edit pages. Classes required for JS: * none Data attributes required for JS: * none Context variables required for this template: * overridesnav - must alwasy be present. * addoverridebutton - should only be present if the user has permission to add a new override. Example context (json): { "id": "url_select_test", "action": "https://example.com/post", "formid": "url_select_form", "sesskey": "sesskey", "navmenu": { "options": [{ "name": "Questions", "value": "/mod/quiz/edit.php?cmid=123" }, { "name": "Complex grading", "value": "/mod/quiz/editgrading.php?cmid=123" } ] } } }}
{{#navmenu}} {{/navmenu}}
PK9B\n*templates/submission_confirmation.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 mod_quiz/submission_confirmation Submission confirmation dialogue template Example context (json): { "hasunanswered": false, "totalunanswered": 0 } }}
{{#str}} confirmclose, mod_quiz {{/str}}
{{#hasunanswered}}
{{#str}} submission_confirmation_unanswered, mod_quiz, {{totalunanswered}} {{/str}}
{{/hasunanswered}}
PK9B\aYp $templates/overrides_actions.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 mod_quiz/overrides_actions Actions bar at the top of the overrides page UI. Classes required for JS: * none Data attributes required for JS: * none Context variables required for this template: * overridesnav - must alwasy be present. * addoverridebutton - should only be present if the user has permission to add a new override. Example context (json): { "overridesnav": { "id": "url_select_test", "action": "https://example.com/post", "formid": "url_select_form", "sesskey": "sesskey", "label": "core/url_select", "options": [{ "name": "User overrides", "value": "/mod/quiz/overrides.php?cmid=71&mode=user" }, { "name": "Group overrides", "value": "/mod/quiz/overrides.php?cmid=71&mode=group" } ], "disabled": false, "title": "Some cool title" }, "addoverridebutton": { "id": "single_button1234", "type": "primary", "method": "get", "url": "http://localhost/moodle/mod/quiz/overrideedit.php", "label": "Add user override", "classes": "singlebutton", "disabled": false, "params": [ { "name": "cmid", "value": "73" }, { "name": "action", "value": "adduser" } ] } } }}
{{#overridesnav}} {{/overridesnav}} {{#addoverridebutton}} {{/addoverridebutton}}
PK9B\%templates/switch_bank_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 mod_quiz/switch_bank_header Template for showing the currently selected bank as a header with a switch bank button. Classes required for JS: * none Data attributes required for JS: * none Context variables required for this template: * title A cleaned string (use clean_text()) to display. * body HTML content for the boday Example context (json): { "currentbank": "Quiz 1" } }}
{{#str}}currentbank, mod_quiz, {{currentbank}} {{/str}}
PK9B\54+templates/add_random_question_form.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 mod_quiz/add_random_question_form From to add random questions. Example context (json): { "returnurl": "http://example.com", "randomoptions": [ {"name": "1", "value": "1"}, {"name": "2", "value": "2"} ], "questioncategoryoptions": { "label": "Category 1", "options": [ {"name": "Category 2", "value": "1"}, {"name": "Category 3", "value": "2"} ] }, "questioncategoryhelp": { "title": "Help with something", "text": "Help with something", "url": "http://example.org/help", "linktext": "", "icon": { "extraclasses": "", "attributes": [ {"name": "src", "value": "../../../pix/help.svg"}, {"name": "alt", "value": "Help icon"} ] } }, "questionbank": "
Question bank HTML goes here
" } }}
{{^slotid}} {{#str}} randomfromexistingcategory, mod_quiz {{/str}} {{/slotid}} {{{questionbank}}} {{^slotid}} {{/slotid}}
{{^slotid}}
{{#str}} randomquestionusinganewcategory, mod_quiz {{/str}}
{{#questioncategoryhelp}} {{>core/help_icon}} {{/questioncategoryhelp}}
{{/slotid}}
PK9B\X."#templates/question_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 . }} {{! Question chooser. }} {{> core/chooser }} {{#js}} require([ 'core/yui' ], function(Y) { Y.use('moodle-mod_quiz-questionchooser', function() { M.mod_quiz.init_questionchooser(); }); }); {{/js}} PK9B\?/templates/update_filter_condition_form.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 mod_quiz/update_filter_condition_form From to add random questions. Example context (json): { "questionbank": "
Question bank HTML goes here
", "returnurl": "https://example.com", "cmid": "1234", "slotid": "5678" } }}
{{{questionbank}}}
PK9B\|{,templates/random_question_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 mod_quiz/random_question_container Container for list of filtered questions. Example context (json): { } }}
PK9B\xz=templates/random_question_form_preview_question_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 mod_quiz/random_question_form_preview_question_list A preview section for the list of questions that match the random question criteria in the random question form. Classes required for JS: * none Data attributes required for JS: * none Context variables required for this template: * title A cleaned string (use clean_text()) to display. * body HTML content for the boday Example context (json): { "questions": [ { "name":"Example question 1" }, { "name":"Example question 2" }, { "name":"Example question 3" }, { "name":"Example question 4" } ] } }}
    {{#questions}}
  • {{#icon}}{{#pix}} {{key}}, {{component}}, {{{alttext}}} {{/pix}}{{/icon}} {{{name}}}
  • {{/questions}}
PK9B\P7 templates/question_slot.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 mod_quiz/question_slot This template renders the question slot content. Example context (json): { "slotid": "1", "canbeedited": true, "checkbox": "", "questionnumber": "Question 1", "questionname": "This is a test question", "questionicons": "", "questiondependencyicon": "", "versionselection": true, "draftversion": false, "bankname": "Question bank 1", "issharedbank": true, "bankurl": "http://example.com/question/edit.php?cmid=1&cat=1%2C1", "versionoptions": [], "versionoption": [ { "versionid": 0, "version": 0, "versionvalue": "Always latest", "selected": true }, { "versionid": "1", "version": "1", "questionid": "1", "selected": false, "versionvalue": "v1 (latest)" } ] } }}
{{{checkbox}}} {{{questionnumber}}}
{{{questionname}}}
{{#issharedbank}} {{/issharedbank}}
{{#versionselection}} {{/versionselection}} {{{questionicons}}}
{{#canbeedited}} {{{questiondependencyicon}}} {{/canbeedited}}
{{#draftversion}} {{/draftversion}} PK9B\Ntemplates/timer.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 mod_quiz/timer Login page template Example context (json): { } }}
{{#str}} timeleft, quiz {{/str}}
PK9B\ ]u$templates/edit_grading_page.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 mod_quiz/edit_grading_page Display the Quiz complex grading page, from below tertiary nav, to above the footer. Classes required for JS: * none. Data attributes required for JS: * Many. See mod_quiz/edit_grading module. Context variables required for this template: * TBC Example context (json): { "quizid": 42, "hasgradeitems": true, "gradeitems": [ { "id": 123, "sortorder": 1, "name": "Reading & writing", "displayname": "Reading & writing", "summarks": "10.00", "isused": true } ], "hasslots": true, "sections": [ { "displayname": "Section title", "slots": [ { "id": 234, "displaynumber": "1", "displayname": "Question name", "maxmark": "10.00", "choices": [ { "id": 0, "choice": "[none]", "isselected": false }, { "id": 123, "choice": "Reading & writing", "isselected": true } ] } ] } ], "nogradeitems": { "message": "This quiz does not yet have any grade items defined." }, "noslots": { "message": "No questions have been added to the quiz yet. Please add the questions to the quiz before setting up grading." } } }}

{{#str}} gradeitemsetup, quiz {{/str}}

{{^hasgradeitems}} {{#nogradeitems}} {{> core/notification_info}} {{/nogradeitems}} {{/hasgradeitems}} {{#hasgradeitems}} {{#gradeitems}} {{/gradeitems}}
{{#str}} gradeitems, grades {{/str}}
{{#str}} gradeitem, grades {{/str}} {{#str}} totalmarks, quiz {{/str}} {{#str}} actions {{/str}}
{{{displayname}}} {{#pix}}t/editstring, core{{/pix}} {{summarks}} {{^isused}} {{#pix}} t/delete, core, {{#str}} gradeitemdelete, quiz, {{{name}}} {{/str}} {{/pix}} {{/isused}}
{{/hasgradeitems}}
{{^hasgradeitems}} {{#hasmultiplesections}} {{/hasmultiplesections}} {{/hasgradeitems}}

{{#str}} gradeitemmarkscheme, quiz {{/str}}

{{^hasslots}} {{#noslots}} {{> core/notification_info}} {{/noslots}} {{/hasslots}} {{#hasslots}} {{#sections}} {{#slots}} {{/slots}} {{/sections}}
{{#str}} gradeitemmarkscheme, quiz {{/str}}
{{#str}} numberabbr, quiz {{/str}} {{#str}} question, quiz {{/str}} {{#str}} marks, quiz {{/str}} {{#str}} gradeitem, grades {{/str}}

{{{displayname}}}

{{displaynumber}} {{maxmark}}
{{/hasslots}}
{{#hasgradeitems}}

{{#str}} gradeitemsremovealltitle, quiz {{/str}}

{{/hasgradeitems}}
PK9B\_ś.templates/attempt_summary_information.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 mod_quiz/attempt_summary_information This template renders summary information about a single quiz attempt. Example context (json): { "hasitems": true, "items": [ { "title": "It is me!", "content": "Sam Student" }, {"title": "Status", "content": "Finished"}, {"title": "Started", "content": "Thursday, 23 November 2023, 9:29 AM"}, {"title": "Completed", "content": "Thursday, 23 November 2023, 9:32 AM"}, {"title": "Duration", "content": "3 minutes"}, {"title": "Grade", "content": "Not yet graded"} ], "caption": "Summary of attempt" } }} {{#hasitems}} {{#caption}} {{/caption}} {{#items}} {{/items}}
{{caption}}
{{{title}}} {{{content}}}
{{/hasitems}} PK9B\(tO&O& edit_rest.phpnu[. /** * Rest endpoint for ajax editing of quiz structure. * * @package mod_quiz * @copyright 1999 Martin Dougiamas http://dougiamas.com * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ use mod_quiz\quiz_settings; if (!defined('AJAX_SCRIPT')) { define('AJAX_SCRIPT', true); } require_once(__DIR__ . '/../../config.php'); require_once($CFG->dirroot . '/mod/quiz/locallib.php'); // Initialise ALL the incoming parameters here, up front. $quizid = required_param('quizid', PARAM_INT); $class = required_param('class', PARAM_ALPHA); $field = optional_param('field', '', PARAM_ALPHA); $instanceid = optional_param('instanceId', 0, PARAM_INT); $sectionid = optional_param('sectionId', 0, PARAM_INT); $previousid = optional_param('previousid', 0, PARAM_INT); $value = optional_param('value', 0, PARAM_INT); $column = optional_param('column', 0, PARAM_ALPHA); $id = optional_param('id', 0, PARAM_INT); $summary = optional_param('summary', '', PARAM_RAW); $sequence = optional_param('sequence', '', PARAM_SEQUENCE); $visible = optional_param('visible', 0, PARAM_INT); $pageaction = optional_param('action', '', PARAM_ALPHA); // Used to simulate a DELETE command. $maxmark = optional_param('maxmark', '', PARAM_LOCALISEDFLOAT); $newheading = optional_param('newheading', '', PARAM_TEXT); $shuffle = optional_param('newshuffle', 0, PARAM_INT); $page = optional_param('page', '', PARAM_INT); $ids = optional_param('ids', '', PARAM_SEQUENCE); $PAGE->set_url('/mod/quiz/edit-rest.php', ['quizid' => $quizid, 'class' => $class]); require_sesskey(); $quizobj = quiz_settings::create($quizid); $quiz = $quizobj->get_quiz(); $cm = $quizobj->get_cm(); $course = $quizobj->get_course(); require_login($course, false, $cm); $structure = $quizobj->get_structure(); $gradecalculator = $quizobj->get_grade_calculator(); $modcontext = $quizobj->get_context(); echo $OUTPUT->header(); // Send headers. // All these AJAX actions should be logically atomic. $transaction = $DB->start_delegated_transaction(); // OK, now let's process the parameters and do stuff // MDL-10221 the DELETE method is not allowed on some web servers, // so we simulate it with the action URL param. $requestmethod = $_SERVER['REQUEST_METHOD']; if ($pageaction == 'DELETE') { $requestmethod = 'DELETE'; } $result = null; switch($requestmethod) { case 'POST': case 'GET': // For debugging. switch ($class) { case 'section': $table = 'quiz_sections'; $section = $structure->get_section_by_id($id); switch ($field) { case 'getsectiontitle': require_capability('mod/quiz:manage', $modcontext); $result = ['instancesection' => $section->heading]; break; case 'updatesectiontitle': require_capability('mod/quiz:manage', $modcontext); $structure->set_section_heading($id, $newheading); $result = ['instancesection' => format_string($newheading)]; break; case 'updateshufflequestions': require_capability('mod/quiz:manage', $modcontext); $structure->set_section_shuffle($id, $shuffle); $result = ['instanceshuffle' => $section->shufflequestions]; break; } break; case 'resource': switch ($field) { case 'move': require_capability('mod/quiz:manage', $modcontext); if (!$previousid) { $section = $structure->get_section_by_id($sectionid); if ($section->firstslot > 1) { $previousid = $structure->get_slot_id_for_slot($section->firstslot - 1); $page = $structure->get_page_number_for_slot($section->firstslot); } } $structure->move_slot($id, $previousid, $page); quiz_delete_previews($quiz); $result = ['visible' => true]; break; case 'getmaxmark': require_capability('mod/quiz:manage', $modcontext); $slot = $DB->get_record('quiz_slots', ['id' => $id], '*', MUST_EXIST); $result = ['instancemaxmark' => quiz_format_question_grade($quiz, $slot->maxmark)]; break; case 'updatemaxmark': require_capability('mod/quiz:manage', $modcontext); $slot = $structure->get_slot_by_id($id); if ($structure->update_slot_maxmark($slot, $maxmark)) { // Grade has really changed. quiz_delete_previews($quiz); $gradecalculator->recompute_quiz_sumgrades(); $gradecalculator->recompute_all_attempt_sumgrades(); $gradecalculator->recompute_all_final_grades(); quiz_update_grades($quiz, 0, true); } $result = ['instancemaxmark' => quiz_format_question_grade($quiz, $maxmark), 'newsummarks' => quiz_format_grade($quiz, $quiz->sumgrades)]; break; case 'updatepagebreak': require_capability('mod/quiz:manage', $modcontext); $slots = $structure->update_page_break($id, $value); $json = []; foreach ($slots as $slot) { $json[$slot->slot] = ['id' => $slot->id, 'slot' => $slot->slot, 'page' => $slot->page]; } quiz_delete_previews($quiz); $result = ['slots' => $json]; break; case 'deletemultiple': require_capability('mod/quiz:manage', $modcontext); $ids = explode(',', $ids); foreach ($ids as $id) { $slot = $DB->get_record('quiz_slots', ['quizid' => $quiz->id, 'id' => $id], '*', MUST_EXIST); if ($structure->has_use_capability($slot->slot)) { $structure->remove_slot($slot->slot); } } quiz_delete_previews($quiz); $gradecalculator->recompute_quiz_sumgrades(); $result = ['newsummarks' => quiz_format_grade($quiz, $quiz->sumgrades), 'deleted' => true, 'newnumquestions' => $structure->get_question_count()]; break; case 'updatedependency': require_capability('mod/quiz:manage', $modcontext); $slot = $structure->get_slot_by_id($id); $value = (bool) $value; $structure->update_question_dependency($slot->id, $value); $result = ['requireprevious' => $value]; break; } break; } break; case 'DELETE': switch ($class) { case 'section': require_capability('mod/quiz:manage', $modcontext); $structure->remove_section_heading($id); $result = ['deleted' => true]; break; case 'resource': require_capability('mod/quiz:manage', $modcontext); if (!$slot = $DB->get_record('quiz_slots', ['quizid' => $quiz->id, 'id' => $id])) { throw new moodle_exception('AJAX commands.php: Bad slot ID '.$id); } if (!$structure->has_use_capability($slot->slot)) { $slotdetail = $structure->get_slot_by_id($slot->id); $context = context::instance_by_id($slotdetail->contextid); throw new required_capability_exception($context, 'moodle/question:useall', 'nopermissions', ''); } $structure->remove_slot($slot->slot); quiz_delete_previews($quiz); $gradecalculator->recompute_quiz_sumgrades(); $result = ['newsummarks' => quiz_format_grade($quiz, $quiz->sumgrades), 'deleted' => true, 'newnumquestions' => $structure->get_question_count()]; break; } break; } $transaction->allow_commit(); echo json_encode($result); PK9B\o|44accessmanager_form.phpnu[. /** * File only retained to prevent fatal errors in code that tries to require/include this. * * @todo MDL-76612 delete this file as part of Moodle 4.6 development. * @deprecated This file is no longer required in Moodle 4.2+. */ defined('MOODLE_INTERNAL') || die(); debugging('This file is no longer required in Moodle 4.2+. Please do not include/require it.', DEBUG_DEVELOPER); PK9B\ZXX 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 the quiz code. === 4.4 === * A quiz_structure_modified callback has been added for quiz_ plugins, called from grade_calculator::recompute_quiz_sumgrades(). Plugins can implement this by creating a `quiz_structure_modified` class in their namespace with a static `callback` method, see quiz_statistics as an example. * External functions returning question information, currently get_attempt_summary(), get_attempt_data() and get_attemp_review() now return a new field called "stateclass". A machine-readable class name for the state that this question attempt is in, as returned by question_usage_by_activity::get_question_state_class(). * External function mod_quiz_external::get_attempt_summary() now returns a new field "totalunanswered", with the total number of unanswered questions. * quiz_settings::no_review_message now takes a new argument $attemptsubmittime for the time when the quiz attempt was submitted. It is strongly recommended that you always pass that. * In the renderer, related to rendering the review page, review_summary_table and filter_review_summary_table have been replaced by review_attempt_summary and filter_review_attempt_summary. This is to support changing the $summarydata argument the review_page() method to an instance of a new templateable class attempt_summary_information for displaying this. The $summarydata argument of review_question_page has also been changed to an attempt_summary_information. * In the renderer, the view_table has been deprecated. Please use the list_of_attempts renderable instead. * There is a new function qbank_helper::get_version_information_for_questions_in_attempt to efficiently check whether each question in an attempt is using the latest versions. This is used by re-grading, replacing some old code there, and to update teacher previews automatically. * The following previously deprecated methods have been removed and can no longer be used: - `get_slot_tags_for_slot_id` - `quiz_retrieve_tags_for_slot_ids` * Quiz override logic has been refactored and put in a new override_manager class. * Quiz override cache should now be accessed using the new override_cache class. * The quiz_delete_overrides and quiz_delete_all_overrides functions are now deprecated. Please instead use: - override_manager::delete_override_by_id - override_manager::delete_overrides - override_manager::delete_all_overrides * There is a new renderable grade_out_of to help with display a nicely formatted "42.00 out of 100.00" with a few variants. * The quiz now supports computing multiple total grades for each attempt. To support this there is a new database table quiz_grade_items and a new column quizgradeitemid in quiz_slots. There are new methods in structure grade_calculator and quiz_attempt to support this, and the new grades are shown on review.php, view.php and in some of the quiz reports. Also, the external functions get_attempt_review and get_user_attempts return the information about the extra grades if applicable. === 4.3 === * The method get_questions() has a new parameter 'requirequestionfullyloaded' which can be used to instruct whether the questions should be fully loaded or not. * the quiz_settings and quiz_attempt classes now always store the ->cm property as a cm_info class. In the distant past it was always a stdClass, then at one point there was an undocumented change making it sometimes a stdClass and sometimes a cm_info. Now it is consistently a cm_info. Type hints have been updated to reflect this. * The parameter filtercondition in the web service mod_quiz_add_random_questions is now optional. This parameter is only used when adding random questions from an existing category. It is not required when adding random questions from a new category since it is not used in this case. === 4.2 === * For the three quiz web services: mod_quiz_get_attempt_data, mod_quiz_get_attempt_summary and mod_quiz_get_attempt_review, the ->number property of each question is now deprecated (It had the wrong time and was documented wrongly.) In the future, please use the new property ->questionnumber. Note, this question 'number' can include any string (e.g. 'Qs 1 & 2') so it is important to applying htmlspecialchars, or equivalent, to the value if you are outputting to HTML. * The methods in the quiz_settings class which return a URL now all return a moodle_url. Previously some returns a moodle_url and others aa string. * 'firstslotid' value is not used in section_shuffle_updated event anymore. * The quiz has a lot of old classes in lib.php files. These have now been moved into the classes folder, and so are now in namespaces. Because of Moodle's class renaming support, your code should continue working, but output deprecated warnings, so you probably want to update. This should mostly be doable by adding use statements, and a search/replace. - mod_quiz_display_options => mod_quiz\question\display_options - qubaids_for_quiz => mod_quiz\question\qubaids_for_quiz - qubaids_for_quiz_user => mod_quiz\question\qubaids_for_quiz_user - which is deprecated, because it is almost the same as mod_quiz\question\qubaids_for_users_attempts. - mod_quiz_admin_setting_browsersecurity => mod_quiz\admin\browser_security_setting - mod_quiz_admin_setting_grademethod => mod_quiz\admin\grade_method_setting - mod_quiz_admin_setting_overduehandling => mod_quiz\admin\overdue_handling_setting - mod_quiz_admin_review_setting => mod_quiz\admin\review_setting - mod_quiz_admin_setting_user_image => mod_quiz\admin\user_image_setting - mod_quiz_admin_setting_browsersecurity => mod_quiz\admin\browser_security_setting - mod_quiz_admin_setting_grademethod => mod_quiz\admin\grade_method_setting - mod_quiz_admin_setting_overduehandling => mod_quiz\admin\overdue_handling_setting - mod_quiz_admin_review_setting => mod_quiz\admin\review_setting - mod_quiz_admin_setting_user_image => mod_quiz\admin\user_image_setting - mod_quiz\adminpresets\adminpresets_mod_quiz_admin_setting_browsersecurity => mod_quiz\adminpresets\adminpresets_browser_security_setting - mod_quiz\adminpresets/adminpresets_mod_quiz_admin_setting_grademethod => mod_quiz\adminpresets\adminpresets_grade_method_setting - mod_quiz\adminpresets\adminpresets_mod_quiz_admin_setting_overduehandling => mod_quiz\adminpresets\adminpresets_overdue_handling_setting - mod_quiz\adminpresets\adminpresets_mod_quiz_admin_review_setting => mod_quiz\adminpresets\adminpresets_review_setting - mod_quiz\adminpresets\adminpresets_mod_quiz_admin_setting_user_image => mod_quiz\adminpresets\adminpresets_user_image_setting - quiz_default_report => mod_quiz\local\reports\report_base - quiz_attempts_report => mod_quiz\local\reports\attempts_report - mod_quiz_attempts_report_form => mod_quiz\local\reports\attempts_report_options_form - mod_quiz_attempts_report_options => mod_quiz\local\reports\attempts_report_options - quiz_attempts_report_table => mod_quiz\local\reports\attempts_report_table - quiz_access_manager => mod_quiz\access_manager - mod_quiz_preflight_check_form => mod_quiz\form\preflight_check_form - quiz_override_form => mod_quiz\form\edit_override_form - quiz_access_rule_base => mod_quiz\local\access_rule_base - quiz_add_random_form => mod_quiz\form\add_random_form - mod_quiz_links_to_other_attempts => mod_quiz\output\links_to_other_attempts - mod_quiz_view_object => mod_quiz\output\view_page - mod_quiz_renderer => mod_quiz\output\renderer - quiz_nav_question_button => mod_quiz\output\navigation_question_button - quiz_nav_section_heading => mod_quiz\output\navigation_section_heading - quiz_nav_panel_base => mod_quiz\output\navigation_panel_base - quiz_attempt_nav_panel => mod_quiz\output\navigation_panel_attempt - quiz_review_nav_panel => mod_quiz\output\navigation_panel_review - quiz_attempt => mod_quiz\quiz_attempt - quiz => mod_quiz\quiz_settings - quizaccess_seb\quiz_settings => quizaccess_seb\seb_quiz_settings - quizaccess_seb\access_manager => quizaccess_seb\seb_access_manager * The following classes have been deprecated: - mod_quiz_overdue_attempt_updater - merged into mod_quiz\task\update_overdue_attempts - moodle_quiz_exception - just use normal moodle_exception * As part of the clean-up, the following files are no longer required, and if you try to include them, you will get a debugging notices telling you not to: - mod/quiz/report/attemptsreport.php - mod/quiz/report/attemptsreport_form.php - mod/quiz/report/attemptsreport_options.php - mod/quiz/report/attemptsreport_table.php - mod/quiz/report/default.php - mod/quiz/accessmanager.php - mod/quiz/accessmanager_form.php - mod/quiz/cronlib.php - mod/quiz/override_form.php - mod/quiz/accessrule/accessrulebase.php - mod/quiz/renderer.php - actually, no debugging ouput for this one because of how renderer factories work. - mod/quiz/attemptlib.php * Various functions related to calculating grades have moved into a new class mod_quiz\grade_calculator. You get that using $quizobj->get_grade_calculator(), then the following old functions have become these new methods. - quiz_update_sumgrades -> recompute_quiz_sumgrades - quiz_update_all_attempt_sumgrades -> recompute_all_attempt_sumgrades - quiz_update_all_final_grades -> recompute_all_final_grades - quiz_set_grade -> update_quiz_maximum_grade - quiz_save_best_grade -> recompute_final_grade - quiz_calculate_best_grade -> [no public direct replacement] - quiz_calculate_best_attempt - [no replacement. It was not used.] * Final deprecation (complete removal) of the following functions which were deprecated long ago: - quiz_groups_member_added_handler - deprecated since 2.6 - quiz_groups_member_removed_handler - deprecated since 2.6 - quiz_groups_group_deleted_handler - deprecated since 2.6 - quiz_groups_members_removed_handler - deprecated since 2.6 - The method quiz_settings::confirm_start_attempt_message - deprecated in Moodle 3.1 - The field view_page::$startattemptwarning - deprecated in Moodle 3.1 - attempts_report::load_relevant_students - deprecated since 3.2 - quiz_statistics_graph_get_new_colour - deprecated since 3.2 - The file mod/quiz/report/overview/overviewgraph.php - deprecated since 3.2 - The file mod/quiz/report/statistics/statistics_graph.php - deprecated since 3.2 - quiz_print_overview - deprecated since 3.3 * For properties that were previously only declared dynamically, a few classes now include property declarations to support PHP 8.2. The affected classes are: - restore_quiz_activity_structure_step - attempts_report_table - attempts_report - quiz_overview_report (Removing $hasgroupstudents, because the parent attempts_report class has the same variable.) === 4.1 === * quiz_has_question_use is now deprecated. Use mod_quiz\structure::has_use_capability istead. === 4.0.3, 4.1 === * If a quiz is sequential the external functions mod_quiz_external::get_attempt_data, mod_quiz_external::get_attempt_summary will not return any data related to out of sequence question except while reviewing the quiz (MDL-75210). === 4.0.2, 4.1 === * No external code should be calling quiz_overview_report::regrade_attempt because it is an internal method of the quiz_overview plugin. But if you are incorrectly using it, be aware that the API changed slightly. It now returns an array listing any questions which could not be regraded. * New method called get_number_of_unanswered_questions() has been added to mod/quiz/attemptlib.php to get the total number of unanswered questions in the current attempt. === 4.0 === * The following API methods have a new parameter, $studentisonline, to define whether the student is currently interacting: - process_finish() in mod/quiz/attemptlib.php - quiz_send_confirmation() in mod/quiz/locallib.php - quiz_send_notification_messages() in mod/quiz/locallib.php * The completionpass criteria has been moved to core as 'completionpassgrade'. Refer to completion/upgrade.txt for further information. * New argument quizhasquestions has been added to public methods: view_information in mod/quiz/renderer.php. * The function no_questions_message() in class mod_quiz_renderer is deprecated. There is no replacement. * Related to the Moodle 4.0 question bank changes, the quiz_slots database table. The fields removed are now manage by new core_question tables: - question_references -> Records where a specific question is used. - question_set_references -> Records where groups of questions are used (e.g. random questions). * The quiz_slots_tags database table has been removed entirely, as has the get_slot_tags_for_slot_id() method from mod/quiz/classes/structure.php and the the locallib.php functions quiz_retrieve_slot_tags and quiz_retrieve_slot_tag_ids. This information is now stored in question_set_references and can be accessed in the results of qbank_helper::get_question_structure. === 3.11 === * External function mod_quiz_external::get_user_best_grade now returns and additional optional field: - gradetopass: The grade to pass the quiz (if set) === 3.10.1 === * External functions mod_quiz_external::get_attempt_data, mod_quiz_external::get_attempt_summary and mod_quiz_external::get_attempt_review now return a new additional optional field: - settings: Containing the question definition settings for displaying the question in an external system. === 3.10 === * External functions mod_quiz_external::get_attempt_data, mod_quiz_external::get_attempt_summary and mod_quiz_external::get_attempt_review now return a new additional optional field: - responsefileareas: Containing the user responses to questions file area names including files. === 3.7 === * Quiz_cron() has been removed. Sub-plugins should implemented scheduled tasks, however legacy cron in subplugins are supported. === 3.6 === * The following renamed classes have been completely removed: - quiz_question_bank_view (now: mod_quiz\question\bank\custom_view) - question_bank_add_to_quiz_action_column (now: mod_quiz\question\bank\add_action_column) - question_bank_question_name_text_column (now: mod_quiz\question\bank\question_name_text_column) === 3.5 === * Removed questionbank.ajax.php. Please use the quiz_question_bank fragment instead. * Adding "random" questions to a quiz via quiz_add_quiz_question() has been deprecated. Please use quiz_add_random_questions(). === 3.3.2 === * quiz_refresh_events() Now takes two additional parameters to refine the update to a specific instance. This function now optionally takes the module instance object or ID, and the course module object or ID. Please try to send the full objects instead of the ids to save DB calls. === 3.2 === * External functions mod_quiz_external::get_attempt_data, mod_quiz_external::get_attempt_summary and mod_quiz_external::get_attempt_review now return additional optional fields: - blockedbyprevious: Whether a question is blocked by the previous question. === 3.1 === * quiz_attempt::question_print_comment_fields() has been removed. It was broken since at least Moodle 2.0. * quiz::confirm_start_attempt_message and mod_quiz_view_object::$startattemptwarning have been deprecated. This functionality is now entirely handled within the quiz access rule plugins. * The third argument to mod_quiz_renderer::start_attempt_button has been changed from a warning string to a mod_quiz_preflight_check_form. * mod_quiz_renderer::review_next_navigation has a new optional argument. If you have overridden that method, consider updating your code to match. * mod_quiz\output\edit_renderer::start_section_list now takes $structure as an argument. If you have overridden this method (it's hard to believe anyone ever would) you will need to update your renderer. * Several methods relating to preview links/buttons/urls have a new optional argument to make the preview be of a particular variant. === 2.9 === * There have been changes in classes/output/edit_renderer.php for MDL-40990. + Some methods use to take $structure & $question as the first two arguments. They now take $structure & $slot number. If you need $question, you can get it using $question = $structure->get_question_in_slot($slot); + Some methods used to take $quiz & $question. They now take $structure & $slot number. You can get $question as above. $quiz is $structure->get_quiz(). + initialise_editing_javascript has had some redundant arguments removed. Hopefully, with these changes, we will have less need to make other changes in future. * Due to MDL-40992, you should be aware that extra slots can get added to an attempt. You may get slot numbers beyone the end of the original quiz layout, and you may want to call $attemptobj->get_original_slot to find where the question originally came from. * You now need to pass an instance of the mod_quiz_renderer if you call $attemptobj->render_question or $attemptobj->render_question_at_step. * The array values in mod_quiz_links_to_other_attempts may now be either a moodle_url, or renderable (or null). Previously they could only be a moodle_url or null. * The contents of the navigation block is now not just quiz_nav_question_button-s. It can also contain quiz_nav_section_heading-s. If you have overridden mod_quiz_renderer::navigation_panel, then you may need to account for this. This change also has implications for the Edit quiz page. === 2.8 === * Classes that were defined in various lib files have been moved to the classes folder to take advantage of auto-loading. This has involved renaming them. see the list in mod/quiz/db/renamedclasses.php. * The quiz no longer handles its own \mod_quiz\event\attempt_becameoverdue event, and so the event handler function quiz_attempt_overdue_handler has been deleted. Also, the internal function quiz_send_overdue_message has add the arguments changed. It now takes the $attemptobj object, not separate stdClass objects. * Major changes to the Edit quiz page. The goal of this work was to increase usability, and also clean up the page enough that it will be possible to add new features in future. Display of mod/quiz/edit.php is now entirely generated by mod_quiz\output\edit_renderer. This uses a helper class mod_quiz\structure to provide details of the structure of the quiz, and mod_quiz\repaginate to alter that structure. (Actually, there are still some modification methods on mod_quiz\structure. Expect that to be cleaned up in future.) The new code uses much more ajax, and there are new scripts mod/quiz/edit_rest.php and mod/quiz/repaginate.php to handle this. (Again, don't be surprised if those two scripts get merged in future.) Also questionbank.ajax.php (which may, in future, be made more generic, and moved into the core question bank code.) As a result of this, mod/quiz/editlib.php has gone. (A few remaining functions were moved to locallib.php.) Here is a list of all the old functions or classes that have changed. If you used any of these in custom code, you will need to update your code. (Note that many of these functions should have been considered private internals of the quiz module, and you should not have been using them!) From editlib.php: quiz_remove_slot quiz_delete_empty_page quiz_add_page_break_after_slot - Use methods of structure or repaginate quiz_update_slot_maxmark - classes instead. _quiz_move_question quiz_move_question_up quiz_move_question_down quiz_print_question_list quiz_print_pagecontrols quiz_print_singlequestion - Use methods of edit_renderer instead. quiz_print_randomquestion quiz_print_singlequestion_reordertool quiz_print_randomquestion_reordertool print_random_option_icon quiz_print_grading_form quiz_print_status_bar Moved from editlib.php to locallib.php: quiz_question_tostring - now always returns a string (the only option used). The $return argument has gone. Old editing JavaScript (e.g. mod/quiz/edit.js) is gone. Replaced with YUI modules. === 2.7.1 === * The function quiz_fire_attempt_started_event has been removed. This function should not have been used outside the quiz, but if you were using it, you should trigger the event outside this function. Note that the appropriate start event is fired automatically by the quiz_attempt_save_started function. === 2.7 === * The old quiz.questions database column (comma-separated list of question ids) is gone, and instead the quiz_question_instances table has been renamed to to quiz_slots. Some of the columns of that table have been renamed to match the coding guidelines. Specifically: quiz -> quizid question -> questionid grade -> maxmark also there are two new columns: slot - numbers the questions in the quiz in order, as on the edit quiz page. page - new way to determine which question is on which page. naturally, other parts of the code and APIs have been updated to reflect that change. * The following functions, which were part of the internal workings of the quiz, have been removed. quiz_get_slot_for_question quiz_number_of_questions_in_quiz quiz_repaginate (there is now a quiz_repaginate_questions with a different API). quiz_add_page_break_at (see quiz_add_page_break_after_slot) quiz_add_page_break_after (see quiz_add_page_break_after_slot) quiz_number_of_pages quiz_remove_question (see quiz_remove_slot) quiz_update_question_instance (see quiz_update_slot_maxmark) * The following internal functions have had their API changed. quiz_delete_empty_page: has had its arguments changed to $quiz and $pagenumber. quiz_has_question_use: now takes $quiz and $slot, not $questionid. === 2.6 === * As part of improving the page usability and accessibility, we updated the heading levels for quiz module so it has a proper nesting. (MDL-41615) * mod_quiz_renderer::view_best_score has been removed. (It did not do what the name suggested anyway.) === 2.4 === * mod_quiz_renderer::finish_review_link now requires $attemptobj to be passed in instead of a moodle_url. === Earlier changes === * Were not documented in this way. Sorry. PK9B\hrۇRR locallib.phpnu[. /** * Library of functions used by the quiz module. * * This contains functions that are called from within the quiz module only * Functions that are also called by core Moodle are in {@link lib.php} * This script also loads the code in {@link questionlib.php} which holds * the module-indpendent code for handling questions and which in turn * initialises all the questiontype classes. * * @package mod_quiz * @copyright 1999 onwards Martin Dougiamas and others {@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 . '/mod/quiz/lib.php'); require_once($CFG->libdir . '/completionlib.php'); require_once($CFG->libdir . '/filelib.php'); require_once($CFG->libdir . '/questionlib.php'); use core\di; use core\hook; use core_question\local\bank\condition; use mod_quiz\access_manager; use mod_quiz\event\attempt_submitted; use mod_quiz\grade_calculator; use mod_quiz\hook\attempt_state_changed; use mod_quiz\local\override_manager; use mod_quiz\question\bank\qbank_helper; use mod_quiz\question\display_options; use mod_quiz\quiz_attempt; use mod_quiz\quiz_settings; use mod_quiz\structure; use qbank_previewquestion\question_preview_options; /** * @var int We show the countdown timer if there is less than this amount of time left before the * the quiz close date. (1 hour) */ define('QUIZ_SHOW_TIME_BEFORE_DEADLINE', '3600'); /** * @var int If there are fewer than this many seconds left when the student submits * a page of the quiz, then do not take them to the next page of the quiz. Instead * close the quiz immediately. */ define('QUIZ_MIN_TIME_TO_CONTINUE', '2'); /** * @var int We show no image when user selects No image from dropdown menu in quiz settings. */ define('QUIZ_SHOWIMAGE_NONE', 0); /** * @var int We show small image when user selects small image from dropdown menu in quiz settings. */ define('QUIZ_SHOWIMAGE_SMALL', 1); /** * @var int We show Large image when user selects Large image from dropdown menu in quiz settings. */ define('QUIZ_SHOWIMAGE_LARGE', 2); // Functions related to attempts /////////////////////////////////////////////// /** * Creates an object to represent a new attempt at a quiz * * Creates an attempt object to represent an attempt at the quiz by the current * user starting at the current time. The ->id field is not set. The object is * NOT written to the database. * * @param quiz_settings $quizobj the quiz object to create an attempt for. * @param int $attemptnumber the sequence number for the attempt. * @param stdClass|false $lastattempt the previous attempt by this user, if any. Only needed * if $attemptnumber > 1 and $quiz->attemptonlast is true. * @param int $timenow the time the attempt was started at. * @param bool $ispreview whether this new attempt is a preview. * @param int|null $userid the id of the user attempting this quiz. * * @return stdClass the newly created attempt object. */ function quiz_create_attempt(quiz_settings $quizobj, $attemptnumber, $lastattempt, $timenow, $ispreview = false, $userid = null) { global $USER; if ($userid === null) { $userid = $USER->id; } $quiz = $quizobj->get_quiz(); if ($quiz->sumgrades < grade_calculator::ALMOST_ZERO && $quiz->grade > grade_calculator::ALMOST_ZERO) { throw new moodle_exception('cannotstartgradesmismatch', 'quiz', new moodle_url('/mod/quiz/view.php', ['q' => $quiz->id]), ['grade' => quiz_format_grade($quiz, $quiz->grade)]); } if ($attemptnumber == 1 || !$quiz->attemptonlast) { // We are not building on last attempt so create a new attempt. $attempt = new stdClass(); $attempt->quiz = $quiz->id; $attempt->userid = $userid; $attempt->preview = 0; $attempt->layout = ''; } else { // Build on last attempt. if (empty($lastattempt)) { throw new \moodle_exception('cannotfindprevattempt', 'quiz'); } $attempt = $lastattempt; } $attempt->attempt = $attemptnumber; $attempt->timefinish = 0; $attempt->timemodified = $timenow; $attempt->timemodifiedoffline = 0; $attempt->currentpage = 0; $attempt->sumgrades = null; $attempt->gradednotificationsenttime = null; $attempt->timecheckstate = null; // If this is a preview, mark it as such. if ($ispreview) { $attempt->preview = 1; } return $attempt; } /** * Start a normal, new, quiz attempt. * * @param quiz_settings $quizobj the quiz object to start an attempt for. * @param question_usage_by_activity $quba * @param stdClass $attempt * @param integer $attemptnumber starting from 1 * @param integer $timenow the attempt start time * @param array $questionids slot number => question id. Used for random questions, to force the choice * of a particular actual question. Intended for testing purposes only. * @param array $forcedvariantsbyslot slot number => variant. Used for questions with variants, * to force the choice of a particular variant. Intended for testing * purposes only. * @return stdClass modified attempt object */ function quiz_start_new_attempt($quizobj, $quba, $attempt, $attemptnumber, $timenow, $questionids = [], $forcedvariantsbyslot = []) { // Usages for this user's previous quiz attempts. $qubaids = new \mod_quiz\question\qubaids_for_users_attempts( $quizobj->get_quizid(), $attempt->userid); // Partially load all the questions in this quiz. $quizobj->preload_questions(); // First load all the non-random questions. $randomfound = false; $slot = 0; $questions = []; $maxmark = []; $page = []; foreach ($quizobj->get_questions(null, false) as $questiondata) { $slot += 1; $maxmark[$slot] = $questiondata->maxmark; $page[$slot] = $questiondata->page; if ($questiondata->status == \core_question\local\bank\question_version_status::QUESTION_STATUS_DRAFT) { throw new moodle_exception('questiondraftonly', 'mod_quiz', '', $questiondata->name); } if ($questiondata->qtype == 'random') { $randomfound = true; continue; } $questions[$slot] = question_bank::load_question($questiondata->questionid, $quizobj->get_quiz()->shuffleanswers); } // Then find a question to go in place of each random question. if ($randomfound) { $slot = 0; $usedquestionids = []; foreach ($questions as $question) { if ($question->id && isset($usedquestions[$question->id])) { $usedquestionids[$question->id] += 1; } else { $usedquestionids[$question->id] = 1; } } $randomloader = new \core_question\local\bank\random_question_loader($qubaids, $usedquestionids); foreach ($quizobj->get_questions(null, false) as $questiondata) { $slot += 1; if ($questiondata->qtype != 'random') { continue; } $tagids = qbank_helper::get_tag_ids_for_slot($questiondata); // Deal with fixed random choices for testing. if (isset($questionids[$quba->next_slot_number()])) { $filtercondition = $questiondata->filtercondition; $filters = $filtercondition['filter'] ?? []; if ($randomloader->is_filtered_question_available($filters, $questionids[$quba->next_slot_number()])) { $questions[$slot] = question_bank::load_question( $questionids[$quba->next_slot_number()], $quizobj->get_quiz()->shuffleanswers); continue; } else { throw new coding_exception('Forced question id not available.'); } } // Normal case, pick one at random. $filtercondition = $questiondata->filtercondition; $filters = $filtercondition['filter'] ?? []; $questionid = $randomloader->get_next_filtered_question_id($filters); if ($questionid === null) { throw new moodle_exception('notenoughrandomquestions', 'quiz', $quizobj->view_url(), $questiondata); } $questions[$slot] = question_bank::load_question($questionid, $quizobj->get_quiz()->shuffleanswers); } } // Finally add them all to the usage. ksort($questions); foreach ($questions as $slot => $question) { $newslot = $quba->add_question($question, $maxmark[$slot]); if ($newslot != $slot) { throw new coding_exception('Slot numbers have got confused.'); } } // Start all the questions. $variantstrategy = new core_question\engine\variants\least_used_strategy($quba, $qubaids); if (!empty($forcedvariantsbyslot)) { $forcedvariantsbyseed = question_variant_forced_choices_selection_strategy::prepare_forced_choices_array( $forcedvariantsbyslot, $quba); $variantstrategy = new question_variant_forced_choices_selection_strategy( $forcedvariantsbyseed, $variantstrategy); } $quba->start_all_questions($variantstrategy, question_attempt_step::TIMECREATED_ON_FIRST_RENDER, $attempt->userid); // Work out the attempt layout. $sections = $quizobj->get_sections(); foreach ($sections as $i => $section) { if (isset($sections[$i + 1])) { $sections[$i]->lastslot = $sections[$i + 1]->firstslot - 1; } else { $sections[$i]->lastslot = count($questions); } } $layout = []; foreach ($sections as $section) { if ($section->shufflequestions) { $questionsinthissection = []; for ($slot = $section->firstslot; $slot <= $section->lastslot; $slot += 1) { $questionsinthissection[] = $slot; } shuffle($questionsinthissection); $questionsonthispage = 0; foreach ($questionsinthissection as $slot) { if ($questionsonthispage && $questionsonthispage == $quizobj->get_quiz()->questionsperpage) { $layout[] = 0; $questionsonthispage = 0; } $layout[] = $slot; $questionsonthispage += 1; } } else { $currentpage = $page[$section->firstslot]; for ($slot = $section->firstslot; $slot <= $section->lastslot; $slot += 1) { if ($currentpage !== null && $page[$slot] != $currentpage) { $layout[] = 0; } $layout[] = $slot; $currentpage = $page[$slot]; } } // Each section ends with a page break. $layout[] = 0; } $attempt->layout = implode(',', $layout); return $attempt; } /** * Start a subsequent new attempt, in each attempt builds on last mode. * * @param question_usage_by_activity $quba this question usage * @param stdClass $attempt this attempt * @param stdClass $lastattempt last attempt * @return stdClass modified attempt object * */ function quiz_start_attempt_built_on_last($quba, $attempt, $lastattempt) { $oldquba = question_engine::load_questions_usage_by_activity($lastattempt->uniqueid); $oldnumberstonew = []; foreach ($oldquba->get_attempt_iterator() as $oldslot => $oldqa) { $question = $oldqa->get_question(false); if ($question->status == \core_question\local\bank\question_version_status::QUESTION_STATUS_DRAFT) { throw new moodle_exception('questiondraftonly', 'mod_quiz', '', $question->name); } $newslot = $quba->add_question($question, $oldqa->get_max_mark()); $quba->start_question_based_on($newslot, $oldqa); $oldnumberstonew[$oldslot] = $newslot; } // Update attempt layout. $newlayout = []; foreach (explode(',', $lastattempt->layout) as $oldslot) { if ($oldslot != 0) { $newlayout[] = $oldnumberstonew[$oldslot]; } else { $newlayout[] = 0; } } $attempt->layout = implode(',', $newlayout); return $attempt; } /** * Create or update the quiz attempt record, and the question usage. * * If the attempt already exists in the database with the NOT_STARTED state, it will be transitioned * to IN_PROGRESS and the timestart updated. If it does not already exist, a new record will be created * already in the IN_PROGRESS state. * * @param quiz_settings $quizobj * @param question_usage_by_activity $quba * @param stdClass $attempt * @param ?int $timenow The time to use for the attempt's timestart property. Defaults to time(). * @return stdClass attempt object with uniqueid and id set. */ function quiz_attempt_save_started( quiz_settings $quizobj, question_usage_by_activity $quba, \stdClass $attempt, ?int $timenow = null, ): stdClass { global $DB; $attempt->timestart = $timenow ?? time(); $timeclose = $quizobj->get_access_manager($attempt->timestart)->get_end_time($attempt); if ($timeclose === false || $attempt->preview) { $attempt->timecheckstate = null; } else { $attempt->timecheckstate = $timeclose; } $originalattempt = null; if (isset($attempt->id) && $attempt->state === quiz_attempt::NOT_STARTED) { $originalattempt = clone $attempt; // In case questions have been edited since attempts were pre-created, update questions now. quiz_attempt::create($attempt->id)->update_questions_to_new_version_if_changed(); // Update the attempt's state. $attempt->state = quiz_attempt::IN_PROGRESS; $DB->update_record('quiz_attempts', $attempt); } else { // Save the attempt in the database. question_engine::save_questions_usage_by_activity($quba); $attempt->uniqueid = $quba->get_id(); $attempt->state = quiz_attempt::IN_PROGRESS; $attempt->id = $DB->insert_record('quiz_attempts', $attempt); } // Params used by the events below. $params = [ 'objectid' => $attempt->id, 'relateduserid' => $attempt->userid, 'courseid' => $quizobj->get_courseid(), 'context' => $quizobj->get_context() ]; // Decide which event we are using. if ($attempt->preview) { $params['other'] = [ 'quizid' => $quizobj->get_quizid() ]; $event = \mod_quiz\event\attempt_preview_started::create($params); } else { $event = \mod_quiz\event\attempt_started::create($params); } // Trigger the event. $event->add_record_snapshot('quiz', $quizobj->get_quiz()); $event->add_record_snapshot('quiz_attempts', $attempt); $event->trigger(); di::get(hook\manager::class)->dispatch(new attempt_state_changed($originalattempt, $attempt)); return $attempt; } /** * Create the quiz attempt record, and the question usage. * * This saves an attempt in the NOT_STARTED state, and is designed for use when pre-creating attempts * ahead of the quiz start time to spread out the processing load. * * @param question_usage_by_activity $quba * @param stdClass $attempt * @return stdClass attempt object with uniqueid and id set. */ function quiz_attempt_save_not_started(question_usage_by_activity $quba, stdClass $attempt): stdClass { global $DB; // Save the attempt in the database. question_engine::save_questions_usage_by_activity($quba); $attempt->uniqueid = $quba->get_id(); $attempt->state = quiz_attempt::NOT_STARTED; $attempt->id = $DB->insert_record('quiz_attempts', $attempt); di::get(hook\manager::class)->dispatch(new attempt_state_changed(null, $attempt)); return $attempt; } /** * Returns an unfinished attempt (if there is one) for the given * user on the given quiz. This function does not return preview attempts. * * @param int $quizid the id of the quiz. * @param int $userid the id of the user. * * @return mixed the unfinished attempt if there is one, false if not. */ function quiz_get_user_attempt_unfinished($quizid, $userid) { $attempts = quiz_get_user_attempts($quizid, $userid, 'unfinished', true); if ($attempts) { return array_shift($attempts); } else { return false; } } /** * Delete a quiz attempt. * @param mixed $attempt an integer attempt id or an attempt object * (row of the quiz_attempts table). * @param stdClass $quiz the quiz object. */ function quiz_delete_attempt($attempt, $quiz) { global $DB; if (is_numeric($attempt)) { if (!$attempt = $DB->get_record('quiz_attempts', ['id' => $attempt])) { return; } } if ($attempt->quiz != $quiz->id) { debugging("Trying to delete attempt $attempt->id which belongs to quiz $attempt->quiz " . "but was passed quiz $quiz->id."); return; } if (!isset($quiz->cmid)) { $cm = get_coursemodule_from_instance('quiz', $quiz->id, $quiz->course); $quiz->cmid = $cm->id; } question_engine::delete_questions_usage_by_activity($attempt->uniqueid); $DB->delete_records('quiz_attempts', ['id' => $attempt->id]); // Log the deletion of the attempt if not a preview. if (!$attempt->preview) { $params = [ 'objectid' => $attempt->id, 'relateduserid' => $attempt->userid, 'context' => context_module::instance($quiz->cmid), 'other' => [ 'quizid' => $quiz->id ] ]; $event = \mod_quiz\event\attempt_deleted::create($params); $event->add_record_snapshot('quiz_attempts', $attempt); $event->trigger(); // This class callback is deprecated, and will be removed in Moodle 4.8 (MDL-80327). // Use the attempt_state_changed hook instead. $callbackclasses = \core_component::get_plugin_list_with_class('quiz', 'quiz_attempt_deleted'); foreach ($callbackclasses as $callbackclass) { component_class_callback($callbackclass, 'callback', [$quiz->id], null, true); } di::get(hook\manager::class)->dispatch(new attempt_state_changed($attempt, null)); } // Search quiz_attempts for other instances by this user. // If none, then delete record for this quiz, this user from quiz_grades // else recalculate best grade. $userid = $attempt->userid; $gradecalculator = quiz_settings::create($quiz->id)->get_grade_calculator(); if (!$DB->record_exists('quiz_attempts', ['userid' => $userid, 'quiz' => $quiz->id])) { $DB->delete_records('quiz_grades', ['userid' => $userid, 'quiz' => $quiz->id]); } else { $gradecalculator->recompute_final_grade($userid); } quiz_update_grades($quiz, $userid); } /** * Delete all the preview attempts at a quiz, or possibly all the attempts belonging * to one user. * @param stdClass $quiz the quiz object. * @param int $userid (optional) if given, only delete the previews belonging to this user. */ function quiz_delete_previews($quiz, $userid = null) { global $DB; $conditions = ['quiz' => $quiz->id, 'preview' => 1]; if (!empty($userid)) { $conditions['userid'] = $userid; } $previewattempts = $DB->get_records('quiz_attempts', $conditions); foreach ($previewattempts as $attempt) { quiz_delete_attempt($attempt, $quiz); } } /** * @param int $quizid The quiz id. * @return bool whether this quiz has any (non-preview) attempts. */ function quiz_has_attempts($quizid) { global $DB; return $DB->record_exists('quiz_attempts', ['quiz' => $quizid, 'preview' => 0]); } // Functions to do with quiz layout and pages ////////////////////////////////// /** * Repaginate the questions in a quiz * @param int $quizid the id of the quiz to repaginate. * @param int $slotsperpage number of items to put on each page. 0 means unlimited. */ function quiz_repaginate_questions($quizid, $slotsperpage) { global $DB; $trans = $DB->start_delegated_transaction(); $sections = $DB->get_records('quiz_sections', ['quizid' => $quizid], 'firstslot ASC'); $firstslots = []; foreach ($sections as $section) { if ((int)$section->firstslot === 1) { continue; } $firstslots[] = $section->firstslot; } $slots = $DB->get_records('quiz_slots', ['quizid' => $quizid], 'slot'); $currentpage = 1; $slotsonthispage = 0; foreach ($slots as $slot) { if (($firstslots && in_array($slot->slot, $firstslots)) || ($slotsonthispage && $slotsonthispage == $slotsperpage)) { $currentpage += 1; $slotsonthispage = 0; } if ($slot->page != $currentpage) { $DB->set_field('quiz_slots', 'page', $currentpage, ['id' => $slot->id]); } $slotsonthispage += 1; } $trans->allow_commit(); // Log quiz re-paginated event. $cm = get_coursemodule_from_instance('quiz', $quizid); $event = \mod_quiz\event\quiz_repaginated::create([ 'context' => \context_module::instance($cm->id), 'objectid' => $quizid, 'other' => [ 'slotsperpage' => $slotsperpage ] ]); $event->trigger(); } // Functions to do with quiz grades //////////////////////////////////////////// // Note a lot of logic related to this is now in the grade_calculator class. /** * Convert the raw grade stored in $attempt into a grade out of the maximum * grade for this quiz. * * @param float $rawgrade the unadjusted grade, fof example $attempt->sumgrades * @param stdClass $quiz the quiz object. Only the fields grade, sumgrades and decimalpoints are used. * @param bool|string $format whether to format the results for display * or 'question' to format a question grade (different number of decimal places. * @return float|string the rescaled grade, or null/the lang string 'notyetgraded' * if the $grade is null. */ function quiz_rescale_grade($rawgrade, $quiz, $format = true) { if (is_null($rawgrade)) { $grade = null; } else if ($quiz->sumgrades >= grade_calculator::ALMOST_ZERO) { $grade = $rawgrade * $quiz->grade / $quiz->sumgrades; } else { $grade = 0; } if ($format === 'question') { $grade = quiz_format_question_grade($quiz, $grade); } else if ($format) { $grade = quiz_format_grade($quiz, $grade); } return $grade; } /** * Get the feedback object for this grade on this quiz. * * @param float $grade a grade on this quiz. * @param stdClass $quiz the quiz settings. * @return false|stdClass the record object or false if there is not feedback for the given grade * @since Moodle 3.1 */ function quiz_feedback_record_for_grade($grade, $quiz) { global $DB; // With CBM etc, it is possible to get -ve grades, which would then not match // any feedback. Therefore, we replace -ve grades with 0. $grade = max($grade, 0); $feedback = $DB->get_record_select('quiz_feedback', 'quizid = ? AND mingrade <= ? AND ? < maxgrade', [$quiz->id, $grade, $grade]); return $feedback; } /** * Get the feedback text that should be show to a student who * got this grade on this quiz. The feedback is processed ready for diplay. * * @param float $grade a grade on this quiz. * @param stdClass $quiz the quiz settings. * @param context_module $context the quiz context. * @return string the comment that corresponds to this grade (empty string if there is not one. */ function quiz_feedback_for_grade($grade, $quiz, $context) { if (is_null($grade)) { return ''; } $feedback = quiz_feedback_record_for_grade($grade, $quiz); if (empty($feedback->feedbacktext)) { return ''; } // Clean the text, ready for display. $formatoptions = new stdClass(); $formatoptions->noclean = true; $feedbacktext = file_rewrite_pluginfile_urls($feedback->feedbacktext, 'pluginfile.php', $context->id, 'mod_quiz', 'feedback', $feedback->id); $feedbacktext = format_text($feedbacktext, $feedback->feedbacktextformat, $formatoptions); return $feedbacktext; } /** * @param stdClass $quiz the quiz database row. * @return bool Whether this quiz has any non-blank feedback text. */ function quiz_has_feedback($quiz) { global $DB; static $cache = []; if (!array_key_exists($quiz->id, $cache)) { $cache[$quiz->id] = quiz_has_grades($quiz) && $DB->record_exists_select('quiz_feedback', "quizid = ? AND " . $DB->sql_isnotempty('quiz_feedback', 'feedbacktext', false, true), [$quiz->id]); } return $cache[$quiz->id]; } /** * Return summary of the number of settings override that exist. * * To get a nice display of this, see the quiz_override_summary_links() * quiz renderer method. * * @param stdClass $quiz the quiz settings. Only $quiz->id is used at the moment. * @param cm_info|stdClass $cm the cm object. Only $cm->course, $cm->groupmode and * $cm->groupingid fields are used at the moment. * @param int $currentgroup if there is a concept of current group where this method is being called * (e.g. a report) pass it in here. Default 0 which means no current group. * @return array like 'group' => 3, 'user' => 12] where 3 is the number of group overrides, * and 12 is the number of user ones. */ function quiz_override_summary(stdClass $quiz, cm_info|stdClass $cm, int $currentgroup = 0): array { global $DB; if ($currentgroup) { // Currently only interested in one group. $groupcount = $DB->count_records('quiz_overrides', ['quiz' => $quiz->id, 'groupid' => $currentgroup]); $usercount = $DB->count_records_sql(" SELECT COUNT(1) FROM {quiz_overrides} o JOIN {groups_members} gm ON o.userid = gm.userid WHERE o.quiz = ? AND gm.groupid = ? ", [$quiz->id, $currentgroup]); return ['group' => $groupcount, 'user' => $usercount, 'mode' => 'onegroup']; } $quizgroupmode = groups_get_activity_groupmode($cm); $accessallgroups = ($quizgroupmode == NOGROUPS) || has_capability('moodle/site:accessallgroups', context_module::instance($cm->id)); if ($accessallgroups) { // User can see all groups. $groupcount = $DB->count_records_select('quiz_overrides', 'quiz = ? AND groupid IS NOT NULL', [$quiz->id]); $usercount = $DB->count_records_select('quiz_overrides', 'quiz = ? AND userid IS NOT NULL', [$quiz->id]); return ['group' => $groupcount, 'user' => $usercount, 'mode' => 'allgroups']; } else { // User can only see groups they are in. $groups = groups_get_activity_allowed_groups($cm); if (!$groups) { return ['group' => 0, 'user' => 0, 'mode' => 'somegroups']; } list($groupidtest, $params) = $DB->get_in_or_equal(array_keys($groups)); $params[] = $quiz->id; $groupcount = $DB->count_records_select('quiz_overrides', "groupid $groupidtest AND quiz = ?", $params); $usercount = $DB->count_records_sql(" SELECT COUNT(1) FROM {quiz_overrides} o JOIN {groups_members} gm ON o.userid = gm.userid WHERE gm.groupid $groupidtest AND o.quiz = ? ", $params); return ['group' => $groupcount, 'user' => $usercount, 'mode' => 'somegroups']; } } /** * Efficiently update check state time on all open attempts * * @param array $conditions optional restrictions on which attempts to update * Allowed conditions: * courseid => (array|int) attempts in given course(s) * userid => (array|int) attempts for given user(s) * quizid => (array|int) attempts in given quiz(s) * groupid => (array|int) quizzes with some override for given group(s) * */ function quiz_update_open_attempts(array $conditions) { global $DB; foreach ($conditions as &$value) { if (!is_array($value)) { $value = [$value]; } } $params = []; $wheres = ["quiza.state IN ('inprogress', 'overdue')"]; $iwheres = ["iquiza.state IN ('inprogress', 'overdue')"]; if (isset($conditions['courseid'])) { list ($incond, $inparams) = $DB->get_in_or_equal($conditions['courseid'], SQL_PARAMS_NAMED, 'cid'); $params = array_merge($params, $inparams); $wheres[] = "quiza.quiz IN (SELECT q.id FROM {quiz} q WHERE q.course $incond)"; list ($incond, $inparams) = $DB->get_in_or_equal($conditions['courseid'], SQL_PARAMS_NAMED, 'icid'); $params = array_merge($params, $inparams); $iwheres[] = "iquiza.quiz IN (SELECT q.id FROM {quiz} q WHERE q.course $incond)"; } if (isset($conditions['userid'])) { list ($incond, $inparams) = $DB->get_in_or_equal($conditions['userid'], SQL_PARAMS_NAMED, 'uid'); $params = array_merge($params, $inparams); $wheres[] = "quiza.userid $incond"; list ($incond, $inparams) = $DB->get_in_or_equal($conditions['userid'], SQL_PARAMS_NAMED, 'iuid'); $params = array_merge($params, $inparams); $iwheres[] = "iquiza.userid $incond"; } if (isset($conditions['quizid'])) { list ($incond, $inparams) = $DB->get_in_or_equal($conditions['quizid'], SQL_PARAMS_NAMED, 'qid'); $params = array_merge($params, $inparams); $wheres[] = "quiza.quiz $incond"; list ($incond, $inparams) = $DB->get_in_or_equal($conditions['quizid'], SQL_PARAMS_NAMED, 'iqid'); $params = array_merge($params, $inparams); $iwheres[] = "iquiza.quiz $incond"; } if (isset($conditions['groupid'])) { list ($incond, $inparams) = $DB->get_in_or_equal($conditions['groupid'], SQL_PARAMS_NAMED, 'gid'); $params = array_merge($params, $inparams); $wheres[] = "quiza.quiz IN (SELECT qo.quiz FROM {quiz_overrides} qo WHERE qo.groupid $incond)"; list ($incond, $inparams) = $DB->get_in_or_equal($conditions['groupid'], SQL_PARAMS_NAMED, 'igid'); $params = array_merge($params, $inparams); $iwheres[] = "iquiza.quiz IN (SELECT qo.quiz FROM {quiz_overrides} qo WHERE qo.groupid $incond)"; } // SQL to compute timeclose and timelimit for each attempt: $quizausersql = quiz_get_attempt_usertime_sql( implode("\n AND ", $iwheres)); // SQL to compute the new timecheckstate $timecheckstatesql = " CASE WHEN quizauser.usertimelimit = 0 AND quizauser.usertimeclose = 0 THEN NULL WHEN quizauser.usertimelimit = 0 THEN quizauser.usertimeclose WHEN quizauser.usertimeclose = 0 THEN quiza.timestart + quizauser.usertimelimit WHEN quiza.timestart + quizauser.usertimelimit < quizauser.usertimeclose THEN quiza.timestart + quizauser.usertimelimit ELSE quizauser.usertimeclose END + CASE WHEN quiza.state = 'overdue' THEN quiz.graceperiod ELSE 0 END"; // SQL to select which attempts to process $attemptselect = implode("\n AND ", $wheres); /* * Each database handles updates with inner joins differently: * - mysql does not allow a FROM clause * - postgres and mssql allow FROM but handle table aliases differently * * Different code for each database. */ $dbfamily = $DB->get_dbfamily(); if ($dbfamily == 'mysql') { $updatesql = "UPDATE {quiz_attempts} quiza JOIN {quiz} quiz ON quiz.id = quiza.quiz JOIN ( $quizausersql ) quizauser ON quizauser.id = quiza.id SET quiza.timecheckstate = $timecheckstatesql WHERE $attemptselect"; } else if ($dbfamily == 'postgres') { $updatesql = "UPDATE {quiz_attempts} quiza SET timecheckstate = $timecheckstatesql FROM {quiz} quiz, ( $quizausersql ) quizauser WHERE quiz.id = quiza.quiz AND quizauser.id = quiza.id AND $attemptselect"; } else if ($dbfamily == 'mssql') { $updatesql = "UPDATE quiza SET timecheckstate = $timecheckstatesql FROM {quiz_attempts} quiza JOIN {quiz} quiz ON quiz.id = quiza.quiz JOIN ( $quizausersql ) quizauser ON quizauser.id = quiza.id WHERE $attemptselect"; } else { throw new \core\exception\coding_exception("Unsupported database family: {$dbfamily}"); } $DB->execute($updatesql, $params); } /** * Returns SQL to compute timeclose and timelimit for every attempt, taking into account user and group overrides. * The query used herein is very similar to the one in function quiz_get_user_timeclose, so, in case you * would change either one of them, make sure to apply your changes to both. * * @param string $redundantwhereclauses extra where clauses to add to the subquery * for performance. These can use the table alias iquiza for the quiz attempts table. * @return string SQL select with columns attempt.id, usertimeclose, usertimelimit. */ function quiz_get_attempt_usertime_sql($redundantwhereclauses = '') { if ($redundantwhereclauses) { $redundantwhereclauses = 'WHERE ' . $redundantwhereclauses; } // The multiple qgo JOINS are necessary because we want timeclose/timelimit = 0 (unlimited) to supercede // any other group override $quizausersql = " SELECT iquiza.id, COALESCE(MAX(quo.timeclose), MAX(qgo1.timeclose), MAX(qgo2.timeclose), iquiz.timeclose) AS usertimeclose, COALESCE(MAX(quo.timelimit), MAX(qgo3.timelimit), MAX(qgo4.timelimit), iquiz.timelimit) AS usertimelimit FROM {quiz_attempts} iquiza JOIN {quiz} iquiz ON iquiz.id = iquiza.quiz LEFT JOIN {quiz_overrides} quo ON quo.quiz = iquiza.quiz AND quo.userid = iquiza.userid LEFT JOIN {groups_members} gm ON gm.userid = iquiza.userid LEFT JOIN {quiz_overrides} qgo1 ON qgo1.quiz = iquiza.quiz AND qgo1.groupid = gm.groupid AND qgo1.timeclose = 0 LEFT JOIN {quiz_overrides} qgo2 ON qgo2.quiz = iquiza.quiz AND qgo2.groupid = gm.groupid AND qgo2.timeclose > 0 LEFT JOIN {quiz_overrides} qgo3 ON qgo3.quiz = iquiza.quiz AND qgo3.groupid = gm.groupid AND qgo3.timelimit = 0 LEFT JOIN {quiz_overrides} qgo4 ON qgo4.quiz = iquiza.quiz AND qgo4.groupid = gm.groupid AND qgo4.timelimit > 0 $redundantwhereclauses GROUP BY iquiza.id, iquiz.id, iquiz.timeclose, iquiz.timelimit"; return $quizausersql; } /** * @return array int => lang string the options for calculating the quiz grade * from the individual attempt grades. */ function quiz_get_grading_options() { return [ QUIZ_GRADEHIGHEST => get_string('gradehighest', 'quiz'), QUIZ_GRADEAVERAGE => get_string('gradeaverage', 'quiz'), QUIZ_ATTEMPTFIRST => get_string('attemptfirst', 'quiz'), QUIZ_ATTEMPTLAST => get_string('attemptlast', 'quiz') ]; } /** * @param int $option one of the values QUIZ_GRADEHIGHEST, QUIZ_GRADEAVERAGE, * QUIZ_ATTEMPTFIRST or QUIZ_ATTEMPTLAST. * @return the lang string for that option. */ function quiz_get_grading_option_name($option) { $strings = quiz_get_grading_options(); return $strings[$option]; } /** * @return array string => lang string the options for handling overdue quiz * attempts. */ function quiz_get_overdue_handling_options() { return [ 'autosubmit' => get_string('overduehandlingautosubmit', 'quiz'), 'graceperiod' => get_string('overduehandlinggraceperiod', 'quiz'), 'autoabandon' => get_string('overduehandlingautoabandon', 'quiz'), ]; } /** * Get the choices for what size user picture to show. * @return array string => lang string the options for whether to display the user's picture. */ function quiz_get_user_image_options() { return [ QUIZ_SHOWIMAGE_NONE => get_string('shownoimage', 'quiz'), QUIZ_SHOWIMAGE_SMALL => get_string('showsmallimage', 'quiz'), QUIZ_SHOWIMAGE_LARGE => get_string('showlargeimage', 'quiz'), ]; } /** * Return an user's timeclose for all quizzes in a course, hereby taking into account group and user overrides. * * @param int $courseid the course id. * @return stdClass An object with of all quizids and close unixdates in this course, taking into account the most lenient * overrides, if existing and 0 if no close date is set. */ function quiz_get_user_timeclose($courseid) { global $DB, $USER; // For teacher and manager/admins return timeclose. if (has_capability('moodle/course:update', context_course::instance($courseid))) { $sql = "SELECT quiz.id, quiz.timeclose AS usertimeclose FROM {quiz} quiz WHERE quiz.course = :courseid"; $results = $DB->get_records_sql($sql, ['courseid' => $courseid]); return $results; } $sql = "SELECT q.id, COALESCE(v.userclose, v.groupclose, q.timeclose, 0) AS usertimeclose FROM ( SELECT quiz.id as quizid, MAX(quo.timeclose) AS userclose, MAX(qgo.timeclose) AS groupclose FROM {quiz} quiz LEFT JOIN {quiz_overrides} quo on quiz.id = quo.quiz AND quo.userid = :userid LEFT JOIN {groups_members} gm ON gm.userid = :useringroupid LEFT JOIN {quiz_overrides} qgo on quiz.id = qgo.quiz AND qgo.groupid = gm.groupid WHERE quiz.course = :courseid GROUP BY quiz.id) v JOIN {quiz} q ON q.id = v.quizid"; $results = $DB->get_records_sql($sql, ['userid' => $USER->id, 'useringroupid' => $USER->id, 'courseid' => $courseid]); return $results; } /** * Get the choices to offer for the 'Questions per page' option. * @return array int => string. */ function quiz_questions_per_page_options() { $pageoptions = []; $pageoptions[0] = get_string('neverallononepage', 'quiz'); $pageoptions[1] = get_string('everyquestion', 'quiz'); for ($i = 2; $i <= QUIZ_MAX_QPP_OPTION; ++$i) { $pageoptions[$i] = get_string('everynquestions', 'quiz', $i); } return $pageoptions; } /** * Get the human-readable name for a quiz attempt state. * @param string $state one of the state constants like {@see quiz_attempt::IN_PROGRESS}. * @return string The lang string to describe that state. */ function quiz_attempt_state_name($state) { switch ($state) { case quiz_attempt::NOT_STARTED: return get_string('statenotstarted', 'quiz'); case quiz_attempt::IN_PROGRESS: return get_string('stateinprogress', 'quiz'); case quiz_attempt::OVERDUE: return get_string('stateoverdue', 'quiz'); case quiz_attempt::SUBMITTED: return get_string('statesubmitted', 'quiz'); case quiz_attempt::FINISHED: return get_string('statefinished', 'quiz'); case quiz_attempt::ABANDONED: return get_string('stateabandoned', 'quiz'); default: throw new coding_exception('Unknown quiz attempt state.'); } } // Other quiz functions //////////////////////////////////////////////////////// /** * @param stdClass $quiz the quiz. * @param int $cmid the course_module object for this quiz. * @param stdClass $question the question. * @param string $returnurl url to return to after action is done. * @param int $variant which question variant to preview (optional). * @return string html for a number of icons linked to action pages for a * question - preview and edit / view icons depending on user capabilities. */ function quiz_question_action_icons($quiz, $cmid, $question, $returnurl, $variant = null) { $html = ''; if ($question->qtype !== 'random') { $html = quiz_question_preview_button($quiz, $question, false, $variant); } $html .= quiz_question_edit_button($cmid, $question, $returnurl); return $html; } /** * @param int $cmid the course_module.id for this quiz. * @param stdClass $question the question. * @param string $returnurl url to return to after action is done. * @param string $contentbeforeicon some HTML content to be added inside the link, before the icon. * @return the HTML for an edit icon, view icon, or nothing for a question * (depending on permissions). */ function quiz_question_edit_button($cmid, $question, $returnurl, $contentaftericon = '') { global $CFG, $OUTPUT; // Minor efficiency saving. Only get strings once, even if there are a lot of icons on one page. static $stredit = null; static $strview = null; if ($stredit === null) { $stredit = get_string('edit'); $strview = get_string('view'); } // What sort of icon should we show? $action = ''; if (!empty($question->id) && (question_has_capability_on($question, 'edit') || question_has_capability_on($question, 'move'))) { $action = $stredit; $icon = 't/edit'; } else if (!empty($question->id) && question_has_capability_on($question, 'view')) { $action = $strview; $icon = 'i/info'; } // Build the icon. if ($action) { if ($returnurl instanceof moodle_url) { $returnurl = $returnurl->out_as_local_url(false); } $questionparams = ['returnurl' => $returnurl, 'cmid' => $cmid, 'id' => $question->id]; $questionurl = new moodle_url("$CFG->wwwroot/question/bank/editquestion/question.php", $questionparams); return '' . $OUTPUT->pix_icon($icon, $action) . $contentaftericon . ''; } else if ($contentaftericon) { return '' . $contentaftericon . ''; } else { return ''; } } /** * @param stdClass $quiz the quiz settings * @param stdClass $question the question * @param int $variant which question variant to preview (optional). * @param int $restartversion version of the question to use when restarting the preview. * @return moodle_url to preview this question with the options from this quiz. */ function quiz_question_preview_url($quiz, $question, $variant = null, $restartversion = null) { // Get the appropriate display options. $displayoptions = display_options::make_from_quiz($quiz, display_options::DURING); $maxmark = null; if (isset($question->maxmark)) { $maxmark = $question->maxmark; } // Work out the correcte preview URL. return \qbank_previewquestion\helper::question_preview_url($question->id, $quiz->preferredbehaviour, $maxmark, $displayoptions, $variant, null, null, $restartversion); } /** * @param stdClass $quiz the quiz settings * @param stdClass $question the question * @param bool $label if true, show the preview question label after the icon * @param int $variant which question variant to preview (optional). * @param bool $random if question is random, true. * @return string the HTML for a preview question icon. */ function quiz_question_preview_button($quiz, $question, $label = false, $variant = null, $random = null) { global $PAGE; if (!question_has_capability_on($question, 'use')) { return ''; } $structure = quiz_settings::create($quiz->id)->get_structure(); if (!empty($question->slot)) { $requestedversion = $structure->get_slot_by_number($question->slot)->requestedversion ?? question_preview_options::ALWAYS_LATEST; } else { $requestedversion = question_preview_options::ALWAYS_LATEST; } return $PAGE->get_renderer('mod_quiz', 'edit')->question_preview_icon( $quiz, $question, $label, $variant, $requestedversion); } /** * @param stdClass $attempt the attempt. * @param stdClass $context the quiz context. * @return int whether flags should be shown/editable to the current user for this attempt. */ function quiz_get_flag_option($attempt, $context) { global $USER; if (!has_capability('moodle/question:flag', $context)) { return question_display_options::HIDDEN; } else if ($attempt->userid == $USER->id) { return question_display_options::EDITABLE; } else { return question_display_options::VISIBLE; } } /** * Work out what state this quiz attempt is in - in the sense used by * quiz_get_review_options, not in the sense of $attempt->state. * @param stdClass $quiz the quiz settings * @param stdClass $attempt the quiz_attempt database row. * @return int one of the display_options::DURING, * IMMEDIATELY_AFTER, LATER_WHILE_OPEN or AFTER_CLOSE constants. */ function quiz_attempt_state($quiz, $attempt) { if ($attempt->state == quiz_attempt::IN_PROGRESS) { return display_options::DURING; } else if ($quiz->timeclose && time() >= $quiz->timeclose) { return display_options::AFTER_CLOSE; } else if (time() < $attempt->timefinish + quiz_attempt::IMMEDIATELY_AFTER_PERIOD) { return display_options::IMMEDIATELY_AFTER; } else { return display_options::LATER_WHILE_OPEN; } } /** * The appropriate display_options object for this attempt at this quiz right now. * * @param stdClass $quiz the quiz instance. * @param stdClass $attempt the attempt in question. * @param context $context the quiz context. * * @return display_options */ function quiz_get_review_options($quiz, $attempt, $context) { $options = display_options::make_from_quiz($quiz, quiz_attempt_state($quiz, $attempt)); $options->readonly = true; $options->flags = quiz_get_flag_option($attempt, $context); if (!empty($attempt->id)) { $options->questionreviewlink = new moodle_url('/mod/quiz/reviewquestion.php', ['attempt' => $attempt->id]); } // Show a link to the comment box only for closed attempts. if (!empty($attempt->id) && $attempt->state == quiz_attempt::FINISHED && !$attempt->preview && !is_null($context) && has_capability('mod/quiz:grade', $context)) { $options->manualcomment = question_display_options::VISIBLE; $options->manualcommentlink = new moodle_url('/mod/quiz/comment.php', ['attempt' => $attempt->id]); } if (!is_null($context) && !$attempt->preview && has_capability('mod/quiz:viewreports', $context) && has_capability('moodle/grade:viewhidden', $context)) { // People who can see reports and hidden grades should be shown everything, // except during preview when teachers want to see what students see. $options->attempt = question_display_options::VISIBLE; $options->correctness = question_display_options::VISIBLE; $options->marks = question_display_options::MARK_AND_MAX; $options->feedback = question_display_options::VISIBLE; $options->numpartscorrect = question_display_options::VISIBLE; $options->manualcomment = question_display_options::VISIBLE; $options->generalfeedback = question_display_options::VISIBLE; $options->rightanswer = question_display_options::VISIBLE; $options->overallfeedback = question_display_options::VISIBLE; $options->history = question_display_options::VISIBLE; $options->userinfoinhistory = $attempt->userid; } return $options; } /** * Combines the review options from a number of different quiz attempts. * Returns an array of two ojects, so the suggested way of calling this * funciton is: * list($someoptions, $alloptions) = quiz_get_combined_reviewoptions(...) * * @param stdClass $quiz the quiz instance. * @param array $attempts an array of attempt objects. * * @return array of two options objects, one showing which options are true for * at least one of the attempts, the other showing which options are true * for all attempts. */ function quiz_get_combined_reviewoptions($quiz, $attempts) { $fields = ['feedback', 'generalfeedback', 'rightanswer', 'overallfeedback']; $someoptions = new stdClass(); $alloptions = new stdClass(); foreach ($fields as $field) { $someoptions->$field = false; $alloptions->$field = true; } $someoptions->marks = question_display_options::HIDDEN; $alloptions->marks = question_display_options::MARK_AND_MAX; // This shouldn't happen, but we need to prevent reveal information. if (empty($attempts)) { return [$someoptions, $someoptions]; } foreach ($attempts as $attempt) { $attemptoptions = display_options::make_from_quiz($quiz, quiz_attempt_state($quiz, $attempt)); foreach ($fields as $field) { $someoptions->$field = $someoptions->$field || $attemptoptions->$field; $alloptions->$field = $alloptions->$field && $attemptoptions->$field; } $someoptions->marks = max($someoptions->marks, $attemptoptions->marks); $alloptions->marks = min($alloptions->marks, $attemptoptions->marks); } return [$someoptions, $alloptions]; } // Functions for sending notification messages ///////////////////////////////// /** * Sends a confirmation message to the student confirming that the attempt was processed. * * @param stdClass $recipient user object for the recipient. * @param stdClass $a lots of useful information that can be used in the message * subject and body. * @param bool $studentisonline is the student currently interacting with Moodle? * * @return int|false as for {@link message_send()}. */ function quiz_send_confirmation($recipient, $a, $studentisonline) { // Add information about the recipient to $a. // Don't do idnumber. we want idnumber to be the submitter's idnumber. $a->username = fullname($recipient); $a->userusername = $recipient->username; // Prepare the message. $eventdata = new \core\message\message(); $eventdata->courseid = $a->courseid; $eventdata->component = 'mod_quiz'; $eventdata->name = 'confirmation'; $eventdata->notification = 1; $eventdata->userfrom = core_user::get_noreply_user(); $eventdata->userto = $recipient; $eventdata->subject = get_string('emailconfirmsubject', 'quiz', $a); if ($studentisonline) { $eventdata->fullmessage = get_string('emailconfirmbody', 'quiz', $a); } else { $eventdata->fullmessage = get_string('emailconfirmbodyautosubmit', 'quiz', $a); } $eventdata->fullmessageformat = FORMAT_PLAIN; $eventdata->fullmessagehtml = ''; $eventdata->smallmessage = get_string('emailconfirmsmall', 'quiz', $a); $eventdata->contexturl = $a->quizurl; $eventdata->contexturlname = $a->quizname; $eventdata->customdata = [ 'cmid' => $a->quizcmid, 'instance' => $a->quizid, 'attemptid' => $a->attemptid, ]; // ... and send it. return message_send($eventdata); } /** * Sends notification messages to the interested parties that assign the role capability * * @param stdClass $recipient user object of the intended recipient * @param stdClass $submitter user object for the user who submitted the attempt. * @param stdClass $a associative array of replaceable fields for the templates * * @return int|false as for {@link message_send()}. */ function quiz_send_notification($recipient, $submitter, $a) { global $PAGE; // Recipient info for template. $a->useridnumber = $recipient->idnumber; $a->username = fullname($recipient); $a->userusername = $recipient->username; // Prepare the message. $eventdata = new \core\message\message(); $eventdata->courseid = $a->courseid; $eventdata->component = 'mod_quiz'; $eventdata->name = 'submission'; $eventdata->notification = 1; $eventdata->userfrom = $submitter; $eventdata->userto = $recipient; $eventdata->subject = get_string('emailnotifysubject', 'quiz', $a); $eventdata->fullmessage = get_string('emailnotifybody', 'quiz', $a); $eventdata->fullmessageformat = FORMAT_PLAIN; $eventdata->fullmessagehtml = ''; $eventdata->smallmessage = get_string('emailnotifysmall', 'quiz', $a); $eventdata->contexturl = $a->quizreviewurl; $eventdata->contexturlname = $a->quizname; $userpicture = new user_picture($submitter); $userpicture->size = 1; // Use f1 size. $userpicture->includetoken = $recipient->id; // Generate an out-of-session token for the user receiving the message. $eventdata->customdata = [ 'cmid' => $a->quizcmid, 'instance' => $a->quizid, 'attemptid' => $a->attemptid, 'notificationiconurl' => $userpicture->get_url($PAGE)->out(false), ]; // ... and send it. return message_send($eventdata); } /** * Send all the requried messages when a quiz attempt is submitted. * * @param stdClass $course the course * @param stdClass $quiz the quiz * @param stdClass $attempt this attempt just finished * @param stdClass $context the quiz context * @param stdClass $cm the coursemodule for this quiz * @param bool $studentisonline is the student currently interacting with Moodle? * * @return bool true if all necessary messages were sent successfully, else false. */ function quiz_send_notification_messages($course, $quiz, $attempt, $context, $cm, $studentisonline) { global $CFG, $DB; // Do nothing if required objects not present. if (empty($course) or empty($quiz) or empty($attempt) or empty($context)) { throw new coding_exception('$course, $quiz, $attempt, $context and $cm must all be set.'); } $submitter = $DB->get_record('user', ['id' => $attempt->userid], '*', MUST_EXIST); // Check for confirmation required. $sendconfirm = false; $notifyexcludeusers = ''; if (has_capability('mod/quiz:emailconfirmsubmission', $context, $submitter, false)) { $notifyexcludeusers = $submitter->id; $sendconfirm = true; } // Check for notifications required. $notifyfields = 'u.id, u.username, u.idnumber, u.email, u.emailstop, u.lang, u.timezone, u.mailformat, u.maildisplay, u.auth, u.suspended, u.deleted, '; $userfieldsapi = \core_user\fields::for_name(); $notifyfields .= $userfieldsapi->get_sql('u', false, '', '', false)->selects; $groups = groups_get_all_groups($course->id, $submitter->id, $cm->groupingid); if (is_array($groups) && count($groups) > 0) { $groups = array_keys($groups); } else if (groups_get_activity_groupmode($cm, $course) != NOGROUPS) { // If the user is not in a group, and the quiz is set to group mode, // then set $groups to a non-existant id so that only users with // 'moodle/site:accessallgroups' get notified. $groups = -1; } else { $groups = ''; } $userstonotify = get_users_by_capability($context, 'mod/quiz:emailnotifysubmission', $notifyfields, '', '', '', $groups, $notifyexcludeusers, false, false, true); if (empty($userstonotify) && !$sendconfirm) { return true; // Nothing to do. } $a = new stdClass(); // Course info. $a->courseid = $course->id; $a->coursename = format_string($course->fullname, true, ['context' => $context]); $a->courseshortname = format_string($course->shortname, true, ['context' => $context]); // Quiz info. $a->quizname = format_string($quiz->name, true, ['context' => $context]); $a->quizreporturl = $CFG->wwwroot . '/mod/quiz/report.php?id=' . $cm->id; $a->quizreportlink = '' . $a->quizname . ' report'; $a->quizurl = $CFG->wwwroot . '/mod/quiz/view.php?id=' . $cm->id; $a->quizlink = '' . $a->quizname . ''; $a->quizid = $quiz->id; $a->quizcmid = $cm->id; // Attempt info. $a->submissiontime = userdate($attempt->timefinish); $a->timetaken = format_time($attempt->timefinish - $attempt->timestart); $a->quizreviewurl = $CFG->wwwroot . '/mod/quiz/review.php?attempt=' . $attempt->id; $a->quizreviewlink = '' . $a->quizname . ' review'; $a->attemptid = $attempt->id; // Student who sat the quiz info. $a->studentidnumber = $submitter->idnumber; $a->studentname = fullname($submitter); $a->studentusername = $submitter->username; $allok = true; // Send notifications if required. if (!empty($userstonotify)) { foreach ($userstonotify as $recipient) { $allok = $allok && quiz_send_notification($recipient, $submitter, $a); } } // Send confirmation if required. We send the student confirmation last, so // that if message sending is being intermittently buggy, which means we send // some but not all messages, and then try again later, then teachers may get // duplicate messages, but the student will always get exactly one. if ($sendconfirm) { $allok = $allok && quiz_send_confirmation($submitter, $a, $studentisonline); } return $allok; } /** * Send the notification message when a quiz attempt becomes overdue. * * @param quiz_attempt $attemptobj all the data about the quiz attempt. */ function quiz_send_overdue_message($attemptobj) { global $CFG, $DB; $submitter = $DB->get_record('user', ['id' => $attemptobj->get_userid()], '*', MUST_EXIST); if (!$attemptobj->has_capability('mod/quiz:emailwarnoverdue', $submitter->id, false)) { return; // Message not required. } if (!$attemptobj->has_response_to_at_least_one_graded_question()) { return; // Message not required. } // Prepare lots of useful information that admins might want to include in // the email message. $quizname = format_string($attemptobj->get_quiz_name()); $deadlines = []; if ($attemptobj->get_quiz()->timelimit) { $deadlines[] = $attemptobj->get_attempt()->timestart + $attemptobj->get_quiz()->timelimit; } if ($attemptobj->get_quiz()->timeclose) { $deadlines[] = $attemptobj->get_quiz()->timeclose; } $duedate = min($deadlines); $graceend = $duedate + $attemptobj->get_quiz()->graceperiod; $a = new stdClass(); // Course info. $a->courseid = $attemptobj->get_course()->id; $a->coursename = format_string($attemptobj->get_course()->fullname); $a->courseshortname = format_string($attemptobj->get_course()->shortname); // Quiz info. $a->quizname = $quizname; $a->quizurl = $attemptobj->view_url()->out(false); $a->quizlink = '' . $quizname . ''; // Attempt info. $a->attemptduedate = userdate($duedate); $a->attemptgraceend = userdate($graceend); $a->attemptsummaryurl = $attemptobj->summary_url()->out(false); $a->attemptsummarylink = '' . $quizname . ' review'; // Student's info. $a->studentidnumber = $submitter->idnumber; $a->studentname = fullname($submitter); $a->studentusername = $submitter->username; // Prepare the message. $eventdata = new \core\message\message(); $eventdata->courseid = $a->courseid; $eventdata->component = 'mod_quiz'; $eventdata->name = 'attempt_overdue'; $eventdata->notification = 1; $eventdata->userfrom = core_user::get_noreply_user(); $eventdata->userto = $submitter; $eventdata->subject = get_string('emailoverduesubject', 'quiz', $a); $eventdata->fullmessage = get_string('emailoverduebody', 'quiz', $a); $eventdata->fullmessageformat = FORMAT_PLAIN; $eventdata->fullmessagehtml = ''; $eventdata->smallmessage = get_string('emailoverduesmall', 'quiz', $a); $eventdata->contexturl = $a->quizurl; $eventdata->contexturlname = $a->quizname; $eventdata->customdata = [ 'cmid' => $attemptobj->get_cmid(), 'instance' => $attemptobj->get_quizid(), 'attemptid' => $attemptobj->get_attemptid(), ]; // Send the message. return message_send($eventdata); } /** * Handle the quiz_attempt_submitted event. * * This sends the confirmation and notification messages, if required. * * @param attempt_submitted $event the event object. */ function quiz_attempt_submitted_handler($event) { $course = get_course($event->courseid); $attempt = $event->get_record_snapshot('quiz_attempts', $event->objectid); $quiz = $event->get_record_snapshot('quiz', $attempt->quiz); $cm = get_coursemodule_from_id('quiz', $event->get_context()->instanceid, $event->courseid); $eventdata = $event->get_data(); if (!($course && $quiz && $cm && $attempt)) { // Something has been deleted since the event was raised. Therefore, the // event is no longer relevant. return true; } // Update completion state. $completion = new completion_info($course); if ($completion->is_enabled($cm) && ($quiz->completionattemptsexhausted || $quiz->completionminattempts)) { $completion->update_state($cm, COMPLETION_COMPLETE, $event->userid); } return quiz_send_notification_messages($course, $quiz, $attempt, context_module::instance($cm->id), $cm, $eventdata['other']['studentisonline']); } /** * Send the notification message when a quiz attempt has been manual graded. * * @param quiz_attempt $attemptobj Some data about the quiz attempt. * @param stdClass $userto * @return int|false As for message_send. */ function quiz_send_notify_manual_graded_message(quiz_attempt $attemptobj, object $userto): ?int { global $CFG; $quizname = format_string($attemptobj->get_quiz_name()); $a = new stdClass(); // Course info. $a->courseid = $attemptobj->get_courseid(); $a->coursename = format_string($attemptobj->get_course()->fullname); // Quiz info. $a->quizname = $quizname; $a->quizurl = $CFG->wwwroot . '/mod/quiz/view.php?id=' . $attemptobj->get_cmid(); // Attempt info. $a->attempttimefinish = userdate($attemptobj->get_attempt()->timefinish); // Student's info. $a->studentidnumber = $userto->idnumber; $a->studentname = fullname($userto); $eventdata = new \core\message\message(); $eventdata->component = 'mod_quiz'; $eventdata->name = 'attempt_grading_complete'; $eventdata->userfrom = core_user::get_noreply_user(); $eventdata->userto = $userto; $eventdata->subject = get_string('emailmanualgradedsubject', 'quiz', $a); $eventdata->fullmessage = get_string('emailmanualgradedbody', 'quiz', $a); $eventdata->fullmessageformat = FORMAT_PLAIN; $eventdata->fullmessagehtml = ''; $eventdata->notification = 1; $eventdata->contexturl = $a->quizurl; $eventdata->contexturlname = $a->quizname; // Send the message. return message_send($eventdata); } /** * Logic to happen when a/some group(s) has/have been deleted in a course. * * @param int $courseid The course ID. * @return void */ function quiz_process_group_deleted_in_course($courseid) { $affectedquizzes = override_manager::delete_orphaned_group_overrides_in_course($courseid); if (!empty($affectedquizzes)) { quiz_update_open_attempts(['quizid' => $affectedquizzes]); } } /** * Get the information about the standard quiz JavaScript module. * @return array a standard jsmodule structure. */ function quiz_get_js_module() { global $PAGE; return [ 'name' => 'mod_quiz', 'fullpath' => '/mod/quiz/module.js', 'requires' => ['base', 'dom', 'event-delegate', 'event-key', 'core_question_engine'], 'strings' => [ ['cancel', 'moodle'], ['flagged', 'question'], ['functiondisabledbysecuremode', 'quiz'], ['startattempt', 'quiz'], ['timesup', 'quiz'], ['show', 'moodle'], ['hide', 'moodle'], ], ]; } /** * Creates a textual representation of a question for display. * * @param stdClass $question A question object from the database questions table * @param bool $showicon If true, show the question's icon with the question. False by default. * @param bool $showquestiontext If true (default), show question text after question name. * If false, show only question name. * @param bool $showidnumber If true, show the question's idnumber, if any. False by default. * @param core_tag_tag[]|bool $showtags if array passed, show those tags. Else, if true, get and show tags, * else, don't show tags (which is the default). * @param bool $displaytaglink Indicates whether the tag should be displayed as a link. * @return string HTML fragment. */ function quiz_question_tostring($question, $showicon = false, $showquestiontext = true, $showidnumber = false, $showtags = false, $displaytaglink = true) { global $OUTPUT; $result = ''; // Question name. $name = shorten_text(format_string($question->name), 200); if ($showicon) { $name .= print_question_icon($question) . ' ' . $name; } $result .= html_writer::span($name, 'questionname'); // Question idnumber. if ($showidnumber && $question->idnumber !== null && $question->idnumber !== '') { $result .= ' ' . html_writer::span( html_writer::span(get_string('idnumber', 'question'), 'accesshide') . ' ' . s($question->idnumber), 'badge bg-primary text-white'); } // Question tags. if (is_array($showtags)) { $tags = $showtags; } else if ($showtags) { $tags = core_tag_tag::get_item_tags('core_question', 'question', $question->id); } else { $tags = []; } if ($tags) { $result .= $OUTPUT->tag_list($tags, null, 'd-inline', 0, null, true, $displaytaglink); } // Question text. if ($showquestiontext) { $questiontext = question_utils::to_plain_text($question->questiontext, $question->questiontextformat, ['noclean' => true, 'para' => false, 'filter' => false]); $questiontext = shorten_text($questiontext, 50); if ($questiontext) { $result .= ' ' . html_writer::span(s($questiontext), 'questiontext'); } } return $result; } /** * Verify that the question exists, and the user has permission to use it. * Does not return. Throws an exception if the question cannot be used. * @param int $questionid The id of the question. */ function quiz_require_question_use($questionid) { global $DB; $question = $DB->get_record('question', ['id' => $questionid], '*', MUST_EXIST); question_require_capability_on($question, 'use'); } /** * Add a question to a quiz * * Adds a question to a quiz by updating $quiz as well as the * quiz and quiz_slots tables. It also adds a page break if required. * @param int $questionid The id of the question to be added * @param stdClass $quiz The extended quiz object as used by edit.php * This is updated by this function * @param int $page Which page in quiz to add the question on. If 0 (default), * add at the end * @param float $maxmark The maximum mark to set for this question. (Optional, * defaults to question.defaultmark. * @return bool false if the question was already in the quiz */ function quiz_add_quiz_question($questionid, $quiz, $page = 0, $maxmark = null) { global $DB; if (!isset($quiz->cmid)) { $cm = get_coursemodule_from_instance('quiz', $quiz->id, $quiz->course); $quiz->cmid = $cm->id; } // Make sue the question is not of the "random" type. $questiontype = $DB->get_field('question', 'qtype', ['id' => $questionid]); if ($questiontype == 'random') { throw new coding_exception( 'Adding "random" questions via quiz_add_quiz_question() is deprecated. Please use quiz_add_random_questions().' ); } // If the question type is invalid, we cannot add it to the quiz. It shouldn't be possible to get to this // point without fiddling with the DOM so we can just throw an exception. if (!\question_bank::is_qtype_installed($questiontype)) { throw new coding_exception('Invalid question type: ' . $questiontype); } $trans = $DB->start_delegated_transaction(); $sql = "SELECT qbe.id FROM {quiz_slots} slot JOIN {question_references} qr ON qr.itemid = slot.id JOIN {question_bank_entries} qbe ON qbe.id = qr.questionbankentryid WHERE slot.quizid = ? AND qr.component = ? AND qr.questionarea = ? AND qr.usingcontextid = ?"; $questionslots = $DB->get_records_sql($sql, [$quiz->id, 'mod_quiz', 'slot', context_module::instance($quiz->cmid)->id]); $currententry = get_question_bank_entry($questionid); if (array_key_exists($currententry->id, $questionslots)) { $trans->allow_commit(); return false; } $sql = "SELECT slot.slot, slot.page, slot.id FROM {quiz_slots} slot WHERE slot.quizid = ? ORDER BY slot.slot"; $slots = $DB->get_records_sql($sql, [$quiz->id]); $maxpage = 1; $numonlastpage = 0; foreach ($slots as $slot) { if ($slot->page > $maxpage) { $maxpage = $slot->page; $numonlastpage = 1; } else { $numonlastpage += 1; } } // Add the new instance. $slot = new stdClass(); $slot->quizid = $quiz->id; if ($maxmark !== null) { $slot->maxmark = $maxmark; } else { $slot->maxmark = $DB->get_field('question', 'defaultmark', ['id' => $questionid]); } if (is_int($page) && $page >= 1) { // Adding on a given page. $lastslotbefore = 0; foreach (array_reverse($slots) as $otherslot) { if ($otherslot->page > $page) { $DB->set_field('quiz_slots', 'slot', $otherslot->slot + 1, ['id' => $otherslot->id]); } else { $lastslotbefore = $otherslot->slot; break; } } $slot->slot = $lastslotbefore + 1; $slot->page = min($page, $maxpage + 1); quiz_update_section_firstslots($quiz->id, 1, max($lastslotbefore, 1)); } else { $lastslot = end($slots); if ($lastslot) { $slot->slot = $lastslot->slot + 1; } else { $slot->slot = 1; } if ($quiz->questionsperpage && $numonlastpage >= $quiz->questionsperpage) { $slot->page = $maxpage + 1; } else { $slot->page = $maxpage; } } $slotid = $DB->insert_record('quiz_slots', $slot); // Update or insert record in question_reference table. $sql = "SELECT DISTINCT qr.id, qr.itemid FROM {question} q JOIN {question_versions} qv ON q.id = qv.questionid JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid JOIN {question_references} qr ON qbe.id = qr.questionbankentryid AND qr.version = qv.version JOIN {quiz_slots} qs ON qs.id = qr.itemid WHERE q.id = ? AND qs.id = ? AND qr.component = ? AND qr.questionarea = ?"; $qreferenceitem = $DB->get_record_sql($sql, [$questionid, $slotid, 'mod_quiz', 'slot']); if (!$qreferenceitem) { // Create a new reference record for questions created already. $questionreferences = new stdClass(); $questionreferences->usingcontextid = context_module::instance($quiz->cmid)->id; $questionreferences->component = 'mod_quiz'; $questionreferences->questionarea = 'slot'; $questionreferences->itemid = $slotid; $questionreferences->questionbankentryid = get_question_bank_entry($questionid)->id; $questionreferences->version = null; // Always latest. $DB->insert_record('question_references', $questionreferences); } else if ($qreferenceitem->itemid === 0 || $qreferenceitem->itemid === null) { $questionreferences = new stdClass(); $questionreferences->id = $qreferenceitem->id; $questionreferences->itemid = $slotid; $DB->update_record('question_references', $questionreferences); } else { // If the reference record exits for another quiz. $questionreferences = new stdClass(); $questionreferences->usingcontextid = context_module::instance($quiz->cmid)->id; $questionreferences->component = 'mod_quiz'; $questionreferences->questionarea = 'slot'; $questionreferences->itemid = $slotid; $questionreferences->questionbankentryid = get_question_bank_entry($questionid)->id; $questionreferences->version = null; // Always latest. $DB->insert_record('question_references', $questionreferences); } $trans->allow_commit(); // Log slot created event. $cm = get_coursemodule_from_instance('quiz', $quiz->id); $event = \mod_quiz\event\slot_created::create([ 'context' => context_module::instance($cm->id), 'objectid' => $slotid, 'other' => [ 'quizid' => $quiz->id, 'slotnumber' => $slot->slot, 'page' => $slot->page ] ]); $event->trigger(); } /** * Move all the section headings in a certain slot range by a certain offset. * * @param int $quizid the id of a quiz * @param int $direction amount to adjust section heading positions. Normally +1 or -1. * @param int $afterslot adjust headings that start after this slot. * @param int|null $beforeslot optionally, only adjust headings before this slot. */ function quiz_update_section_firstslots($quizid, $direction, $afterslot, $beforeslot = null) { global $DB; $where = 'quizid = ? AND firstslot > ?'; $params = [$direction, $quizid, $afterslot]; if ($beforeslot) { $where .= ' AND firstslot < ?'; $params[] = $beforeslot; } $firstslotschanges = $DB->get_records_select_menu('quiz_sections', $where, $params, '', 'firstslot, firstslot + ?'); update_field_with_unique_index('quiz_sections', 'firstslot', $firstslotschanges, ['quizid' => $quizid]); } /** * Add a random question to the quiz at a given point. * @param stdClass $quiz the quiz settings. * @param int $addonpage the page on which to add the question. * @param int $categoryid the question category to add the question from. * @param int $number the number of random questions to add. * @deprecated Since Moodle 4.3 MDL-72321 * @todo Final deprecation in Moodle 4.7 MDL-78091 */ function quiz_add_random_questions(stdClass $quiz, int $addonpage, int $categoryid, int $number): void { debugging( 'quiz_add_random_questions is deprecated. Please use mod_quiz\structure::add_random_questions() instead.', DEBUG_DEVELOPER ); $settings = quiz_settings::create($quiz->id); $structure = structure::create_for_quiz($settings); $structure->add_random_questions($addonpage, $number, [ 'filter' => [ 'category' => [ 'jointype' => condition::JOINTYPE_DEFAULT, 'values' => [$categoryid], 'filteroptions' => ['includesubcategories' => false], ], ], ]); } /** * Mark the activity completed (if required) and trigger the course_module_viewed event. * * @param stdClass $quiz quiz object * @param stdClass $course course object * @param stdClass $cm course module object * @param stdClass $context context object * @since Moodle 3.1 */ function quiz_view($quiz, $course, $cm, $context) { $params = [ 'objectid' => $quiz->id, 'context' => $context ]; $event = \mod_quiz\event\course_module_viewed::create($params); $event->add_record_snapshot('quiz', $quiz); $event->trigger(); // Completion. $completion = new completion_info($course); $completion->set_module_viewed($cm); } /** * Validate permissions for creating a new attempt and start a new preview attempt if required. * * @param quiz_settings $quizobj quiz object * @param access_manager $accessmanager quiz access manager * @param bool $forcenew whether was required to start a new preview attempt * @param int $page page to jump to in the attempt * @param bool $redirect whether to redirect or throw exceptions (for web or ws usage) * @return array an array containing the attempt information, access error messages and the page to jump to in the attempt * @since Moodle 3.1 */ function quiz_validate_new_attempt(quiz_settings $quizobj, access_manager $accessmanager, $forcenew, $page, $redirect) { global $DB, $USER; $timenow = time(); if ($quizobj->is_preview_user() && $forcenew) { $accessmanager->current_attempt_finished(); } // Check capabilities. if (!$quizobj->is_preview_user()) { $quizobj->require_capability('mod/quiz:attempt'); } // Check to see if a new preview was requested. if ($quizobj->is_preview_user() && $forcenew) { // To force the creation of a new preview, we mark the current attempt (if any) // as abandoned. It will then automatically be deleted below. $DB->set_field('quiz_attempts', 'state', quiz_attempt::ABANDONED, ['quiz' => $quizobj->get_quizid(), 'userid' => $USER->id]); } // Look for an existing attempt. $attempts = quiz_get_user_attempts($quizobj->get_quizid(), $USER->id, 'all', true); $lastattempt = end($attempts); $attemptnumber = null; if ( $lastattempt && in_array($lastattempt->state, [quiz_attempt::NOT_STARTED, quiz_attempt::IN_PROGRESS, quiz_attempt::OVERDUE]) ) { // If an in-progress or not-started attempt exists, check password then redirect to it. $currentattemptid = $lastattempt->id; $messages = $accessmanager->prevent_access(); // If the attempt is now overdue, deal with that. $quizobj->create_attempt_object($lastattempt)->handle_if_time_expired($timenow, true); // And, if the attempt is now no longer in progress, redirect to the appropriate place. if ($lastattempt->state == quiz_attempt::ABANDONED || $lastattempt->state == quiz_attempt::FINISHED) { if ($redirect) { redirect($quizobj->review_url($lastattempt->id)); } else { throw new moodle_exception('attemptalreadyclosed', 'quiz', $quizobj->view_url()); } } // If the page number was not explicitly in the URL, go to the current page. if ($page == -1) { $page = $lastattempt->currentpage; } } else { while ($lastattempt && $lastattempt->preview) { $lastattempt = array_pop($attempts); } // Get number for the next or unfinished attempt. if ($lastattempt) { $attemptnumber = $lastattempt->attempt + 1; } else { $lastattempt = false; $attemptnumber = 1; } $currentattemptid = null; $messages = $accessmanager->prevent_access() + $accessmanager->prevent_new_attempt(count($attempts), $lastattempt); if ($page == -1) { $page = 0; } } return [$currentattemptid, $attemptnumber, $lastattempt, $messages, $page]; } /** * Prepare and start a new attempt deleting the previous preview attempts. * * @param quiz_settings $quizobj quiz object * @param int $attemptnumber the attempt number * @param stdClass $lastattempt last attempt object * @param bool $offlineattempt whether is an offline attempt or not * @param array $forcedrandomquestions slot number => question id. Used for random questions, * to force the choice of a particular actual question. Intended for testing purposes only. * @param array $forcedvariants slot number => variant. Used for questions with variants, * to force the choice of a particular variant. Intended for testing purposes only. * @param int $userid Specific user id to create an attempt for that user, null for current logged in user * @return stdClass the new attempt * @since Moodle 3.1 */ function quiz_prepare_and_start_new_attempt(quiz_settings $quizobj, $attemptnumber, $lastattempt, $offlineattempt = false, $forcedrandomquestions = [], $forcedvariants = [], $userid = null) { global $DB, $USER; if ($userid === null) { $userid = $USER->id; $ispreviewuser = $quizobj->is_preview_user(); } else { $ispreviewuser = has_capability('mod/quiz:preview', $quizobj->get_context(), $userid); } // Delete any previous preview attempts belonging to this user. quiz_delete_previews($quizobj->get_quiz(), $userid); $quba = question_engine::make_questions_usage_by_activity('mod_quiz', $quizobj->get_context()); $quba->set_preferred_behaviour($quizobj->get_quiz()->preferredbehaviour); $attempt = $DB->get_record( 'quiz_attempts', [ 'quiz' => $quizobj->get_quizid(), 'userid' => $userid, 'preview' => 0, 'state' => quiz_attempt::NOT_STARTED, ], ); if (!$attempt) { // Create the new attempt and initialize the question sessions. $timenow = time(); // Update time now, in case the server is running really slowly. $attempt = quiz_create_attempt($quizobj, $attemptnumber, $lastattempt, $timenow, $ispreviewuser, $userid); if (!($quizobj->get_quiz()->attemptonlast && $lastattempt)) { $attempt = quiz_start_new_attempt( $quizobj, $quba, $attempt, $attemptnumber, $timenow, $forcedrandomquestions, $forcedvariants, ); } else { $attempt = quiz_start_attempt_built_on_last($quba, $attempt, $lastattempt); } } $transaction = $DB->start_delegated_transaction(); // Init the timemodifiedoffline for offline attempts. if ($offlineattempt) { $attempt->timemodifiedoffline = $attempt->timemodified; } $attempt = quiz_attempt_save_started($quizobj, $quba, $attempt); $transaction->allow_commit(); return $attempt; } /** * Check if the given calendar_event is either a user or group override * event for quiz. * * @param calendar_event $event The calendar event to check * @return bool */ function quiz_is_overriden_calendar_event(\calendar_event $event) { global $DB; if (!isset($event->modulename)) { return false; } if ($event->modulename != 'quiz') { return false; } if (!isset($event->instance)) { return false; } if (!isset($event->userid) && !isset($event->groupid)) { return false; } $overrideparams = [ 'quiz' => $event->instance ]; if (isset($event->groupid)) { $overrideparams['groupid'] = $event->groupid; } else if (isset($event->userid)) { $overrideparams['userid'] = $event->userid; } return $DB->record_exists('quiz_overrides', $overrideparams); } /** * Get quiz attempt and handling error. * * @param int $attemptid the id of the current attempt. * @param int|null $cmid the course_module id for this quiz. * @return quiz_attempt all the data about the quiz attempt. */ function quiz_create_attempt_handling_errors($attemptid, $cmid = null) { try { $attempobj = quiz_attempt::create($attemptid); } catch (moodle_exception $e) { if (!empty($cmid)) { list($course, $cm) = get_course_and_cm_from_cmid($cmid, 'quiz'); $continuelink = new moodle_url('/mod/quiz/view.php', ['id' => $cmid]); $context = context_module::instance($cm->id); if (has_capability('mod/quiz:preview', $context)) { throw new moodle_exception('attempterrorcontentchange', 'quiz', $continuelink); } else { throw new moodle_exception('attempterrorcontentchangeforuser', 'quiz', $continuelink); } } else { throw new moodle_exception('attempterrorinvalid', 'quiz'); } } if (!empty($cmid) && $attempobj->get_cmid() != $cmid) { throw new moodle_exception('invalidcoursemodule'); } else { return $attempobj; } } PK9B\j cronlib.phpnu[. /** * Library code used by quiz cron. * * @package mod_quiz * @copyright 2012 the Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @todo MDL-76612 delete this file as part of Moodle 4.6 development. * @deprecated This file is no longer required in Moodle 4.2+. */ defined('MOODLE_INTERNAL') || die(); debugging('This file is no longer required in Moodle 4.2+. Please do not include/require it.', DEBUG_DEVELOPER); require_once($CFG->dirroot . '/mod/quiz/locallib.php'); require_once($CFG->dirroot . '/mod/quiz/deprecatedlib.php'); PK9B\r4reviewquestion.phpnu[. /** * This page prints a review of a particular question attempt. * This page is expected to only be used in a popup window. * * @package mod_quiz * @copyright 1999 onwards Martin Dougiamas {@link http://moodle.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ use mod_quiz\output\attempt_summary_information; require_once(__DIR__ . '/../../config.php'); require_once('locallib.php'); $attemptid = required_param('attempt', PARAM_INT); $slot = required_param('slot', PARAM_INT); $seq = optional_param('step', null, PARAM_INT); $cmid = optional_param('cmid', null, PARAM_INT); $baseurl = new moodle_url('/mod/quiz/reviewquestion.php', ['attempt' => $attemptid, 'slot' => $slot]); $currenturl = new moodle_url($baseurl); if (!is_null($seq)) { $currenturl->param('step', $seq); } $PAGE->set_url($currenturl); $attemptobj = quiz_create_attempt_handling_errors($attemptid, $cmid); $attemptobj->preload_all_attempt_step_users(); // Check login. require_login($attemptobj->get_course(), false, $attemptobj->get_cm()); $attemptobj->check_review_capability(); $student = $DB->get_record('user', ['id' => $attemptobj->get_userid()]); $accessmanager = $attemptobj->get_access_manager(time()); $options = $attemptobj->get_display_options(true); $PAGE->set_pagelayout('popup'); $PAGE->set_title(get_string('reviewofquestion', 'quiz', [ 'question' => format_string($attemptobj->get_question_name($slot)), 'quiz' => format_string($attemptobj->get_quiz_name()), 'user' => fullname($student)])); $PAGE->set_heading($attemptobj->get_course()->fullname); $output = $PAGE->get_renderer('mod_quiz'); // Check permissions - warning there is similar code in review.php and // quiz_attempt::check_file_access. If you change on, change them all. if ($attemptobj->is_own_attempt()) { if (!$attemptobj->is_finished()) { echo $output->review_question_not_allowed($attemptobj, get_string('cannotreviewopen', 'quiz')); die(); } else if (!$options->attempt) { echo $output->review_question_not_allowed($attemptobj, $attemptobj->cannot_review_message()); die(); } } else if (!$attemptobj->is_review_allowed()) { throw new moodle_exception('noreviewattempt', 'quiz', $attemptobj->view_url()); } // Prepare summary informat about this question attempt. $summary = new attempt_summary_information(); // Set the caption. $summary->set_caption(get_string('summaryofattempt', 'quiz')); // Student name. $userpicture = new user_picture($student); $userpicture->courseid = $attemptobj->get_courseid(); $summary->add_item('user', $userpicture, new action_link(new moodle_url('/user/view.php', ['id' => $student->id, 'course' => $attemptobj->get_courseid()]), fullname($student, true))); // Quiz name. $summary->add_item('quizname', get_string('modulename', 'quiz'), format_string($attemptobj->get_quiz_name())); // Question name. $summary->add_item('questionname', get_string('question', 'quiz'), format_string($attemptobj->get_question_name($slot))); // Other attempts at the quiz. if ($attemptobj->has_capability('mod/quiz:viewreports')) { $otherattemptsurl = clone($baseurl); $otherattemptsurl->param('slot', $attemptobj->get_original_slot($slot)); $attemptlist = $attemptobj->links_to_other_attempts($otherattemptsurl); if ($attemptlist) { $summary->add_item('attemptlist', get_string('attempts', 'quiz'), $attemptlist); } } // Timestamp of this action. $timestamp = $attemptobj->get_question_action_time($slot); if ($timestamp) { $summary->add_item('timestamp', get_string('completedon', 'quiz'), userdate($timestamp)); } echo $output->review_question_page($attemptobj, $slot, $seq, $attemptobj->get_display_options(true), $summary); PK9B\3== module.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 camelcase: off */ /** * JavaScript library for the quiz module. * * @package mod * @subpackage quiz * @copyright 1999 onwards Martin Dougiamas {@link http://moodle.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ M.mod_quiz = M.mod_quiz || {}; M.mod_quiz.init_attempt_form = function(Y) { require(['core_question/question_engine'], function(qEngine) { qEngine.initForm('#responseform'); }); Y.on('submit', M.mod_quiz.timer.stop, '#responseform'); require(['core_form/changechecker'], function(FormChangeChecker) { FormChangeChecker.watchFormById('responseform'); }); }; M.mod_quiz.init_review_form = function(Y) { require(['core_question/question_engine'], function(qEngine) { qEngine.initForm('.questionflagsaveform'); }); Y.on('submit', function(e) { e.halt(); }, '.questionflagsaveform'); }; M.mod_quiz.init_comment_popup = function(Y) { // Add a close button to the window. var closebutton = Y.Node.create(''); closebutton.set('value', M.util.get_string('cancel', 'moodle')); Y.one('#id_submitbutton').ancestor().append(closebutton); Y.on('click', function() { window.close() }, closebutton); } // Code for updating the countdown timer that is used on timed quizzes. M.mod_quiz.timer = { // YUI object. Y: null, // Timestamp at which time runs out, according to the student's computer's clock. endtime: 0, // Is this a quiz preview? preview: 0, // This records the id of the timeout that updates the clock periodically, // so we can cancel. timeoutid: null, // Threshold for updating time remaining, in milliseconds. threshold: 3000, /** * @param Y the YUI object * @param start, the timer starting time, in seconds. * @param preview, is this a quiz preview? */ init: function(Y, start, preview) { M.mod_quiz.timer.Y = Y; M.mod_quiz.timer.endtime = M.pageloadstarttime.getTime() + start*1000; M.mod_quiz.timer.preview = preview; M.mod_quiz.timer.update(); Y.one('#quiz-timer-wrapper').setStyle('display', 'flex'); require(['core_form/changechecker'], function(FormChangeChecker) { M.mod_quiz.timer.FormChangeChecker = FormChangeChecker; }); Y.one('#toggle-timer').on('click', function() { M.mod_quiz.timer.toggleVisibility(); }); // We store the visibility as a user preference. If the value is not '1', // i. e. it is '0' or the item does not exist, the timer must be shown. require(['core_user/repository'], function(UserRepository) { UserRepository.getUserPreference('quiz_timerhidden') .then((response) => { M.mod_quiz.timer.setVisibility(response !== '1', false); return; }) // If there is an error, we catch and ignore it, because (i) no matter what we do, // we do not have the stored value, so we will need to take a reasonable default // and (ii) the student who is currently taking the quiz is probably not interested // in the technical details why the fetch failed, even less, because they can hardly // do anything to solve the problem. However, we still log that there was an error // to leave a trace, e. g. for debugging. .catch((error) => { M.mod_quiz.timer.setVisibility(true, false); Y.log(error, 'error', 'moodle-mod_quiz'); }); }); }, /** * Toggle the timer's visibility. */ toggleVisibility: function() { var Y = M.mod_quiz.timer.Y; var timer = Y.one('#quiz-time-left'); // If the timer is currently hidden, the visibility should be set to true and vice versa. this.setVisibility(timer.getAttribute('hidden') === 'hidden'); }, /** * Set visibility of the timer. * @param visible whether the timer should be visible * @param updatePref whether the new status should be stored as a preference */ setVisibility: function(visible, updatePref = true) { var Y = M.mod_quiz.timer.Y; var timer = Y.one('#quiz-time-left'); var button = Y.one('#toggle-timer'); if (visible) { button.setContent(M.util.get_string('hide', 'moodle')); timer.show(); } else { button.setContent(M.util.get_string('show', 'moodle')); timer.hide(); } // Only update the user preference if this has been requested. if (updatePref) { require(['core_user/repository'], function(UserRepository) { UserRepository.setUserPreference('quiz_timerhidden', (visible ? '0' : '1')); }); } }, /** * Stop the timer, if it is running. */ stop: function(e) { if (M.mod_quiz.timer.timeoutid) { clearTimeout(M.mod_quiz.timer.timeoutid); } }, /** * Function to convert a number between 0 and 99 to a two-digit string. */ two_digit: function(num) { if (num < 10) { return '0' + num; } else { return num; } }, // Function to update the clock with the current time left, and submit the quiz if necessary. update: function() { var Y = M.mod_quiz.timer.Y; var secondsleft = Math.floor((M.mod_quiz.timer.endtime - new Date().getTime())/1000); // If time has expired, set the hidden form field that says time has expired and submit if (secondsleft < 0) { M.mod_quiz.timer.stop(null); Y.one('#quiz-time-left').setContent(M.util.get_string('timesup', 'quiz')); var input = Y.one('input[name=timeup]'); input.set('value', 1); var form = input.ancestor('form'); if (form.one('input[name=finishattempt]')) { form.one('input[name=finishattempt]').set('value', 0); } M.mod_quiz.timer.FormChangeChecker.markFormSubmitted(input.getDOMNode()); form.submit(); return; } // If time has nearly expired, change the colour. if (secondsleft < 100) { Y.one('#quiz-timer').removeClass('timeleft' + (secondsleft + 2)) .removeClass('timeleft' + (secondsleft + 1)) .addClass('timeleft' + secondsleft); // From now on, the timer should be visible and should not be hideable anymore. // We use the second (optional) parameter in order to leave the user preference // unchanged. M.mod_quiz.timer.setVisibility(true, false); Y.one('#toggle-timer').setAttribute('disabled', true); } // Update the time display. var hours = Math.floor(secondsleft/3600); secondsleft -= hours*3600; var minutes = Math.floor(secondsleft/60); secondsleft -= minutes*60; var seconds = secondsleft; Y.one('#quiz-time-left').setContent(hours + ':' + M.mod_quiz.timer.two_digit(minutes) + ':' + M.mod_quiz.timer.two_digit(seconds)); // Arrange for this method to be called again soon. M.mod_quiz.timer.timeoutid = setTimeout(M.mod_quiz.timer.update, 100); }, // Allow the end time of the quiz to be updated. updateEndTime: function(timeleft) { var newtimeleft = new Date().getTime() + timeleft * 1000; // Timer might not have been initialized yet. We initialize it with // preview = 0, because it's better to take a preview for a real quiz // than to take a real quiz for a preview. if (M.mod_quiz.timer.Y === null) { M.mod_quiz.timer.init(window.Y, timeleft, 0); } // Only update if change is greater than the threshold, so the // time doesn't bounce around unnecessarily. if (Math.abs(newtimeleft - M.mod_quiz.timer.endtime) > M.mod_quiz.timer.threshold) { M.mod_quiz.timer.endtime = newtimeleft; M.mod_quiz.timer.update(); } } }; M.mod_quiz.filesUpload = { /** * YUI object. */ Y: null, /** * Number of files uploading. */ numberFilesUploading: 0, /** * Disable navigation block when uploading and enable navigation block when all files are uploaded. */ disableNavPanel: function() { var quizNavigationBlock = document.getElementById('mod_quiz_navblock'); if (quizNavigationBlock) { if (M.mod_quiz.filesUpload.numberFilesUploading) { quizNavigationBlock.classList.add('nav-disabled'); } else { quizNavigationBlock.classList.remove('nav-disabled'); } } } }; M.mod_quiz.nav = M.mod_quiz.nav || {}; M.mod_quiz.nav.update_flag_state = function(attemptid, questionid, newstate) { var Y = M.mod_quiz.nav.Y; var navlink = Y.one('#quiznavbutton' + questionid); navlink.removeClass('flagged'); if (newstate == 1) { navlink.addClass('flagged'); navlink.one('.accesshide .flagstate').setContent(M.util.get_string('flagged', 'question')); } else { navlink.one('.accesshide .flagstate').setContent(''); } }; M.mod_quiz.nav.init = function(Y) { M.mod_quiz.nav.Y = Y; Y.all('#quiznojswarning').remove(); var form = Y.one('#responseform'); if (form) { function nav_to_page(pageno) { Y.one('#followingpage').set('value', pageno); // Automatically submit the form. We do it this strange way because just // calling form.submit() does not run the form's submit event handlers. var submit = form.one('input[name="next"]'); submit.set('name', ''); submit.getDOMNode().click(); }; Y.delegate('click', function(e) { if (this.hasClass('thispage')) { return; } e.preventDefault(); var pageidmatch = this.get('href').match(/page=(\d+)/); var pageno; if (pageidmatch) { pageno = pageidmatch[1]; } else { pageno = 0; } var questionidmatch = this.get('href').match(/#question-(\d+)-(\d+)/); if (questionidmatch) { form.set('action', form.get('action') + questionidmatch[0]); } nav_to_page(pageno); }, document.body, '.qnbutton'); } if (Y.one('a.endtestlink')) { Y.on('click', function(e) { e.preventDefault(); nav_to_page(-1); }, 'a.endtestlink'); } // Navigation buttons should be disabled when the files are uploading. require(['core_form/events'], function(formEvent) { document.addEventListener(formEvent.eventTypes.uploadStarted, function() { M.mod_quiz.filesUpload.numberFilesUploading++; M.mod_quiz.filesUpload.disableNavPanel(); }); document.addEventListener(formEvent.eventTypes.uploadCompleted, function() { M.mod_quiz.filesUpload.numberFilesUploading--; M.mod_quiz.filesUpload.disableNavPanel(); }); }); if (M.core_question_flags) { M.core_question_flags.add_listener(M.mod_quiz.nav.update_flag_state); } }; M.mod_quiz.secure_window = { init: function(Y) { if (window.location.href.substring(0, 4) == 'file') { window.location = 'about:blank'; } Y.delegate('contextmenu', M.mod_quiz.secure_window.prevent, document, '*'); Y.delegate('mousedown', M.mod_quiz.secure_window.prevent_mouse, 'body', '*'); Y.delegate('mouseup', M.mod_quiz.secure_window.prevent_mouse, 'body', '*'); Y.delegate('dragstart', M.mod_quiz.secure_window.prevent, document, '*'); Y.delegate('selectstart', M.mod_quiz.secure_window.prevent_selection, document, '*'); Y.delegate('cut', M.mod_quiz.secure_window.prevent, document, '*'); Y.delegate('copy', M.mod_quiz.secure_window.prevent, document, '*'); Y.delegate('paste', M.mod_quiz.secure_window.prevent, document, '*'); Y.on('beforeprint', function() { Y.one(document.body).setStyle('display', 'none'); }, window); Y.on('afterprint', function() { Y.one(document.body).setStyle('display', 'block'); }, window); Y.on('key', M.mod_quiz.secure_window.prevent, '*', 'press:67,86,88+ctrl'); Y.on('key', M.mod_quiz.secure_window.prevent, '*', 'up:67,86,88+ctrl'); Y.on('key', M.mod_quiz.secure_window.prevent, '*', 'down:67,86,88+ctrl'); Y.on('key', M.mod_quiz.secure_window.prevent, '*', 'press:67,86,88+meta'); Y.on('key', M.mod_quiz.secure_window.prevent, '*', 'up:67,86,88+meta'); Y.on('key', M.mod_quiz.secure_window.prevent, '*', 'down:67,86,88+meta'); }, is_content_editable: function(n) { if (n.test('[contenteditable=true]')) { return true; } n = n.get('parentNode'); if (n === null) { return false; } return M.mod_quiz.secure_window.is_content_editable(n); }, prevent_selection: function(e) { return false; }, prevent: function(e) { alert(M.util.get_string('functiondisabledbysecuremode', 'quiz')); e.halt(); }, prevent_mouse: function(e) { if (e.button == 1 && /^(INPUT|TEXTAREA|BUTTON|SELECT|LABEL|A)$/i.test(e.target.get('tagName'))) { // Left click on a button or similar. No worries. return; } if (e.button == 1 && M.mod_quiz.secure_window.is_content_editable(e.target)) { // Left click in Atto or similar. return; } e.halt(); }, /** * Initialize the event listener for the secure window close button * * @param {Object} Y YUI instance. When called from renderer, this parameter precedes the others * @param {String} url */ init_close_button: function(Y, url) { Y.on('click', function(e) { M.mod_quiz.secure_window.close(Y, url, 0); }, '#secureclosebutton'); }, /** * Close the secure window, or redirect to URL if the opener is no longer present * * @param {Object} Y YUI instance. When called from renderer, this parameter precedes the others * @param {String} url * @param {Number} delay */ close: function(Y, url, delay) { setTimeout(function() { if (window.opener) { window.opener.document.location.reload(); window.close(); } else { window.location.href = url; } }, delay*1000); } }; PK9B\h review.phpnu[. /** * This page prints a review of a particular quiz attempt * * It is used either by the student whose attempts this is, after the attempt, * or by a teacher reviewing another's attempt during or afterwards. * * @package mod_quiz * @copyright 1999 onwards Martin Dougiamas {@link http://moodle.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ use mod_quiz\output\attempt_summary_information; use mod_quiz\output\navigation_panel_review; use mod_quiz\output\renderer; use mod_quiz\quiz_attempt; require_once(__DIR__ . '/../../config.php'); require_once($CFG->dirroot . '/mod/quiz/locallib.php'); require_once($CFG->dirroot . '/mod/quiz/report/reportlib.php'); $attemptid = required_param('attempt', PARAM_INT); $page = optional_param('page', 0, PARAM_INT); $showall = optional_param('showall', null, PARAM_BOOL); $cmid = optional_param('cmid', null, PARAM_INT); $url = new moodle_url('/mod/quiz/review.php', ['attempt' => $attemptid]); if ($page !== 0) { $url->param('page', $page); } else if ($showall) { $url->param('showall', $showall); } $PAGE->set_url($url); $PAGE->set_secondary_active_tab("modulepage"); $attemptobj = quiz_create_attempt_handling_errors($attemptid, $cmid); $attemptobj->preload_all_attempt_step_users(); $page = $attemptobj->force_page_number_into_range($page); // Now we can validate the params better, re-genrate the page URL. if ($showall === null) { $showall = $page == 0 && $attemptobj->get_default_show_all('review'); } $PAGE->set_url($attemptobj->review_url(null, $page, $showall)); // Check login. require_login($attemptobj->get_course(), false, $attemptobj->get_cm()); $attemptobj->check_review_capability(); // Create an object to manage all the other (non-roles) access rules. $accessmanager = $attemptobj->get_access_manager(time()); $accessmanager->setup_attempt_page($PAGE); $options = $attemptobj->get_display_options(true); // Check permissions - warning there is similar code in reviewquestion.php and // quiz_attempt::check_file_access. If you change on, change them all. if ($attemptobj->is_own_attempt()) { if (!$attemptobj->is_finished()) { redirect($attemptobj->attempt_url(null, $page)); } else if (!$options->attempt) { $accessmanager->back_to_view_page($PAGE->get_renderer('mod_quiz'), $attemptobj->cannot_review_message()); } } else if (!$attemptobj->is_review_allowed()) { throw new moodle_exception('noreviewattempt', 'quiz', $attemptobj->view_url()); } // Load the questions and states needed by this page. if ($showall) { $questionids = $attemptobj->get_slots(); } else { $questionids = $attemptobj->get_slots($page); } // Save the flag states, if they are being changed. if ($options->flags == question_display_options::EDITABLE && optional_param('savingflags', false, PARAM_BOOL)) { require_sesskey(); $attemptobj->save_question_flags(); redirect($attemptobj->review_url(null, $page, $showall)); } // Work out appropriate title and whether blocks should be shown. if ($attemptobj->is_own_preview()) { navigation_node::override_active_url($attemptobj->start_attempt_url()); $attemptobj->update_questions_to_new_version_if_changed(); } else { if (empty($attemptobj->get_quiz()->showblocks) && !$attemptobj->is_preview_user()) { $PAGE->blocks->show_only_fake_blocks(); } } // Set up the page header. $headtags = $attemptobj->get_html_head_contributions($page, $showall); $PAGE->set_title($attemptobj->review_page_title($page, $showall)); $PAGE->set_heading($attemptobj->get_course()->fullname); $PAGE->activityheader->disable(); $summarydata = attempt_summary_information::create_for_attempt($attemptobj, $options, $page, $showall); if ($showall) { $slots = $attemptobj->get_slots(); $lastpage = true; } else { $slots = $attemptobj->get_slots($page); $lastpage = $attemptobj->is_last_page($page); } /** @var renderer $output */ $output = $PAGE->get_renderer('mod_quiz'); // Arrange for the navigation to be displayed. $navbc = $attemptobj->get_navigation_panel($output, navigation_panel_review::class, $page, $showall); $regions = $PAGE->blocks->get_regions(); $PAGE->blocks->add_fake_block($navbc, reset($regions)); echo $output->review_page($attemptobj, $slots, $page, $showall, $lastpage, $options, $summarydata); // Trigger an event for this review. $attemptobj->fire_attempt_reviewed_event(); PK9B\b_2_2 overrides.phpnu[. /** * This page handles listing of quiz overrides * * @package mod_quiz * @copyright 2010 Matt Petro * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ use mod_quiz\quiz_settings; require_once(__DIR__ . '/../../config.php'); require_once($CFG->dirroot.'/mod/quiz/lib.php'); require_once($CFG->dirroot.'/mod/quiz/locallib.php'); $cmid = required_param('cmid', PARAM_INT); $mode = optional_param('mode', '', PARAM_ALPHA); // One of 'user' or 'group', default is 'group'. $quizobj = quiz_settings::create_for_cmid($cmid); $quiz = $quizobj->get_quiz(); $cm = $quizobj->get_cm(); $course = $quizobj->get_course(); $context = $quizobj->get_context(); require_login($course, false, $cm); // Check the user has the required capabilities to list overrides. $canedit = has_capability('mod/quiz:manageoverrides', $context); if (!$canedit) { require_capability('mod/quiz:viewoverrides', $context); } $quizgroupmode = groups_get_activity_groupmode($cm); $showallgroups = ($quizgroupmode == NOGROUPS) || has_capability('moodle/site:accessallgroups', $context); // Get the course groups that the current user can access. $groups = $showallgroups ? groups_get_all_groups($cm->course) : groups_get_activity_allowed_groups($cm); // Default mode is "group", unless there are no groups. if ($mode != "user" and $mode != "group") { if (!empty($groups)) { $mode = "group"; } else { $mode = "user"; } } $groupmode = ($mode == "group"); $url = new moodle_url('/mod/quiz/overrides.php', ['cmid' => $cm->id, 'mode' => $mode]); $title = get_string('overridesforquiz', 'quiz', format_string($quiz->name, true, ['context' => $context])); $PAGE->set_url($url); $PAGE->set_pagelayout('admin'); $PAGE->add_body_class('limitedwidth'); $PAGE->set_title($title); $PAGE->set_heading($course->fullname); $PAGE->activityheader->disable(); // Activate the secondary nav tab. $PAGE->set_secondary_active_tab("mod_quiz_useroverrides"); // Delete orphaned group overrides. $sql = 'SELECT o.id FROM {quiz_overrides} o LEFT JOIN {groups} g ON o.groupid = g.id WHERE o.groupid IS NOT NULL AND g.id IS NULL AND o.quiz = ?'; $params = [$quiz->id]; $orphaned = $DB->get_records_sql($sql, $params); if (!empty($orphaned)) { $DB->delete_records_list('quiz_overrides', 'id', array_keys($orphaned)); } $overrides = []; $colclasses = []; $headers = []; // Fetch all overrides. if ($groupmode) { $headers[] = get_string('group'); // To filter the result by the list of groups that the current user has access to. if ($groups) { $params = ['quizid' => $quiz->id]; list($insql, $inparams) = $DB->get_in_or_equal(array_keys($groups), SQL_PARAMS_NAMED); $params += $inparams; $sql = "SELECT o.*, g.name FROM {quiz_overrides} o JOIN {groups} g ON o.groupid = g.id WHERE o.quiz = :quizid AND g.id $insql ORDER BY g.name"; $overrides = $DB->get_records_sql($sql, $params); } } else { // User overrides. $colclasses[] = 'colname'; $headers[] = get_string('user'); $userfieldsapi = \core_user\fields::for_identity($context)->with_name()->with_userpic(); $extrauserfields = $userfieldsapi->get_required_fields([\core_user\fields::PURPOSE_IDENTITY]); $userfieldssql = $userfieldsapi->get_sql('u', true, '', 'userid', false); foreach ($extrauserfields as $field) { $colclasses[] = 'col' . $field; $headers[] = \core_user\fields::get_display_name($field); } list($sort, $params) = users_order_by_sql('u', null, $context, $extrauserfields); $params['quizid'] = $quiz->id; if ($showallgroups) { $groupsjoin = ''; $groupswhere = ''; } else if ($groups) { list($insql, $inparams) = $DB->get_in_or_equal(array_keys($groups), SQL_PARAMS_NAMED); $groupsjoin = 'JOIN {groups_members} gm ON u.id = gm.userid'; $groupswhere = ' AND gm.groupid ' . $insql; $params += $inparams; } else { // User cannot see any data. $groupsjoin = ''; $groupswhere = ' AND 1 = 2'; } $overrides = $DB->get_records_sql(" SELECT o.*, {$userfieldssql->selects} FROM {quiz_overrides} o JOIN {user} u ON o.userid = u.id {$userfieldssql->joins} $groupsjoin WHERE o.quiz = :quizid $groupswhere ORDER BY $sort ", array_merge($params, $userfieldssql->params)); } // Initialise table. $table = new html_table(); $table->head = $headers; $table->colclasses = $colclasses; $table->headspan = array_fill(0, count($headers), 1); $table->head[] = get_string('overrides', 'quiz'); $table->colclasses[] = 'colsetting'; $table->colclasses[] = 'colvalue'; $table->headspan[] = 2; if ($canedit) { $table->head[] = get_string('action'); $table->colclasses[] = 'colaction'; $table->headspan[] = 1; } $userurl = new moodle_url('/user/view.php', []); $groupurl = new moodle_url('/group/overview.php', ['id' => $cm->course]); $overridedeleteurl = new moodle_url('/mod/quiz/overridedelete.php'); $overrideediturl = new moodle_url('/mod/quiz/overrideedit.php'); $hasinactive = false; // Whether there are any inactive overrides. foreach ($overrides as $override) { // Check if this override is active. $active = true; if (!$groupmode) { if (!has_capability('mod/quiz:attempt', $context, $override->userid)) { // User not allowed to take the quiz. $active = false; } else if (!\core_availability\info_module::is_user_visible($cm, $override->userid)) { // User cannot access the module. $active = false; } } if (!$active) { $hasinactive = true; } // Prepare the information about which settings are overridden. $fields = []; $values = []; // Format timeopen. if (isset($override->timeopen)) { $fields[] = get_string('quizopens', 'quiz'); $values[] = $override->timeopen > 0 ? userdate($override->timeopen) : get_string('noopen', 'quiz'); } // Format timeclose. if (isset($override->timeclose)) { $fields[] = get_string('quizcloses', 'quiz'); $values[] = $override->timeclose > 0 ? userdate($override->timeclose) : get_string('noclose', 'quiz'); } // Format timelimit. if (isset($override->timelimit)) { $fields[] = get_string('timelimit', 'quiz'); $values[] = $override->timelimit > 0 ? format_time($override->timelimit) : get_string('none', 'quiz'); } // Format number of attempts. if (isset($override->attempts)) { $fields[] = get_string('attempts', 'quiz'); $values[] = $override->attempts > 0 ? $override->attempts : get_string('unlimited'); } // Format password. if (isset($override->password)) { $fields[] = get_string('requirepassword', 'quiz'); $values[] = $override->password !== '' ? get_string('enabled', 'quiz') : get_string('none', 'quiz'); } // Prepare the information about who this override applies to. $extranamebit = $active ? '' : '*'; $usercells = []; if ($groupmode) { $groupcell = new html_table_cell(); $groupcell->rowspan = count($fields); $groupcell->text = html_writer::link(new moodle_url($groupurl, ['group' => $override->groupid]), format_string($override->name, true, ['context' => $context]) . $extranamebit); $usercells[] = $groupcell; } else { $usercell = new html_table_cell(); $usercell->rowspan = count($fields); $usercell->text = html_writer::link(new moodle_url($userurl, ['id' => $override->userid]), fullname($override) . $extranamebit); $usercells[] = $usercell; foreach ($extrauserfields as $field) { $usercell = new html_table_cell(); $usercell->rowspan = count($fields); $usercell->text = s($override->$field); $usercells[] = $usercell; } } // Prepare the actions. if ($canedit) { // Icons. $iconstr = ''; // Edit. $editurlstr = $overrideediturl->out(true, ['id' => $override->id]); $iconstr = '' . $OUTPUT->pix_icon('t/edit', get_string('edit')) . ' '; // Duplicate. $copyurlstr = $overrideediturl->out(true, ['id' => $override->id, 'action' => 'duplicate']); $iconstr .= '' . $OUTPUT->pix_icon('t/copy', get_string('copy')) . ' '; // Delete. $deleteurlstr = $overridedeleteurl->out(true, ['id' => $override->id, 'sesskey' => sesskey()]); $iconstr .= '' . $OUTPUT->pix_icon('t/delete', get_string('delete')) . ' '; $actioncell = new html_table_cell(); $actioncell->rowspan = count($fields); $actioncell->text = $iconstr; } // Add the data to the table. for ($i = 0; $i < count($fields); ++$i) { $row = new html_table_row(); if (!$active) { $row->attributes['class'] = 'dimmed_text'; } if ($i == 0) { $row->cells = $usercells; } $labelcell = new html_table_cell(); $labelcell->text = $fields[$i]; $row->cells[] = $labelcell; $valuecell = new html_table_cell(); $valuecell->text = $values[$i]; $row->cells[] = $valuecell; if ($canedit && $i == 0) { $row->cells[] = $actioncell; } $table->data[] = $row; } } // Work out what else needs to be displayed. $addenabled = true; $warningmessage = ''; if ($canedit) { if ($groupmode) { if (empty($groups)) { // There are no groups. $warningmessage = get_string('groupsnone', 'quiz'); $addenabled = false; } } else { // See if there are any students in the quiz. if ($showallgroups) { $users = get_users_by_capability($context, 'mod/quiz:attempt', 'u.id'); $nousermessage = get_string('usersnone', 'quiz'); } else if ($groups) { $users = get_users_by_capability($context, 'mod/quiz:attempt', 'u.id', '', '', '', array_keys($groups)); $nousermessage = get_string('usersnone', 'quiz'); } else { $users = []; $nousermessage = get_string('groupsnone', 'quiz'); } $info = new \core_availability\info_module($cm); $users = $info->filter_user_list($users); if (empty($users)) { // There are no students. $warningmessage = $nousermessage; $addenabled = false; } } } // Tertiary navigation. echo $OUTPUT->header(); $renderer = $PAGE->get_renderer('mod_quiz'); $tertiarynav = new \mod_quiz\output\overrides_actions($cmid, $mode, $canedit, $addenabled); echo $renderer->render($tertiarynav); if ($mode === 'user') { echo $OUTPUT->heading(get_string('useroverrides', 'quiz')); } else { echo $OUTPUT->heading(get_string('groupoverrides', 'quiz')); } // Output the table and button. echo html_writer::start_tag('div', ['id' => 'quizoverrides']); if (count($table->data)) { echo html_writer::table($table); } else { if ($groupmode) { echo $OUTPUT->notification(get_string('overridesnoneforgroups', 'quiz'), 'info', false); } else { echo $OUTPUT->notification(get_string('overridesnoneforusers', 'quiz'), 'info', false); } } if ($hasinactive) { echo $OUTPUT->notification(get_string('inactiveoverridehelp', 'quiz'), 'info', false); } if ($warningmessage) { echo $OUTPUT->notification($warningmessage, 'error'); } echo html_writer::end_tag('div'); // Finish the page. echo $OUTPUT->footer(); PK9B\cq,accessrule/seb/db/services.phpnu[. /** * List web services and external functions for plugin. * * @package quizaccess_seb * @author Andrew Madden * @copyright 2021 Catalyst IT * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); $functions = [ 'quizaccess_seb_validate_quiz_keys' => [ 'classname' => 'quizaccess_seb\external\validate_quiz_keys', 'methodname' => 'execute', 'description' => 'Validate a Safe Exam Browser config key or a browser exam key.', 'type' => 'read', 'ajax' => true, ], ]; PK9B\YFaccessrule/seb/db/install.phpnu[. /** * Install script for plugin. * * @package quizaccess_seb * @author Andrew Madden * @copyright 2019 Catalyst IT * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); require_once($CFG->dirroot . '/mod/quiz/accessrule/seb/lib.php'); /** * Custom code to be run on installing the plugin. */ function xmldb_quizaccess_seb_install() { global $DB; // Reconfigure all existing quizzes to use a new quizaccess_seb. $params = ['browsersecurity' => 'safebrowser']; $total = $DB->count_records('quiz', $params); if ($total > 0) { $rs = $DB->get_recordset('quiz', $params); $i = 0; $pbar = new progress_bar('updatequizrecords', 500, true); foreach ($rs as $quiz) { if (!$DB->record_exists('quizaccess_seb_quizsettings', ['quizid' => $quiz->id])) { $cm = get_coursemodule_from_instance('quiz', $quiz->id, $quiz->course); $sebsettings = new stdClass(); $sebsettings->quizid = $quiz->id; $sebsettings->cmid = $cm->id; $sebsettings->templateid = 0; $sebsettings->requiresafeexambrowser = \quizaccess_seb\settings_provider::USE_SEB_CLIENT_CONFIG; $sebsettings->showsebtaskbar = null; $sebsettings->showwificontrol = null; $sebsettings->showreloadbutton = null; $sebsettings->showtime = null; $sebsettings->showkeyboardlayout = null; $sebsettings->allowuserquitseb = null; $sebsettings->quitpassword = null; $sebsettings->linkquitseb = null; $sebsettings->userconfirmquit = null; $sebsettings->enableaudiocontrol = null; $sebsettings->muteonstartup = null; $sebsettings->allowcapturecamera = null; $sebsettings->allowcapturemicrophone = null; $sebsettings->allowspellchecking = null; $sebsettings->allowreloadinexam = null; $sebsettings->activateurlfiltering = null; $sebsettings->filterembeddedcontent = null; $sebsettings->expressionsallowed = null; $sebsettings->regexallowed = null; $sebsettings->expressionsblocked = null; $sebsettings->regexblocked = null; $sebsettings->allowedbrowserexamkeys = null; $sebsettings->showsebdownloadlink = 1; $sebsettings->usermodified = get_admin()->id; $sebsettings->timecreated = time(); $sebsettings->timemodified = time(); $DB->insert_record('quizaccess_seb_quizsettings', $sebsettings); $quiz->browsersecurity = '-'; $DB->update_record('quiz', $quiz); } $i++; $pbar->update($i, $total, "Reconfiguring existing quizzes to use a new SEB plugin - $i/$total."); } $rs->close(); } return true; } PK9B\X'"'"accessrule/seb/db/access.phpnu[. /** * Define capabilities for plugin. * * @package quizaccess_seb * @author Andrew Madden * @copyright 2019 Catalyst IT * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); $capabilities = [ 'quizaccess/seb:managetemplates' => [ 'captype' => 'write', 'contextlevel' => CONTEXT_SYSTEM, 'archetypes' => [ 'manager' => CAP_ALLOW, ] ], 'quizaccess/seb:bypassseb' => [ 'captype' => 'read', 'contextlevel' => CONTEXT_MODULE, 'archetypes' => [ 'manager' => CAP_ALLOW, 'editingteacher' => CAP_ALLOW ] ], 'quizaccess/seb:manage_seb_requiresafeexambrowser' => [ 'captype' => 'write', 'contextlevel' => CONTEXT_MODULE, 'archetypes' => [ 'manager' => CAP_ALLOW, 'editingteacher' => CAP_ALLOW ] ], // Ability to select "Yes – Configure manually" as an option for "Require the use of Safe Exam Browser". 'quizaccess/seb:manage_seb_configuremanually' => [ 'captype' => 'read', 'contextlevel' => CONTEXT_MODULE, 'archetypes' => [ 'manager' => CAP_ALLOW, 'editingteacher' => CAP_ALLOW, ], 'clonepermissionsfrom' => 'quizaccess/seb:manage_seb_requiresafeexambrowser', ], // Ability to select "Yes – Use SEB client config" as an option for "Require the use of Safe Exam Browser". 'quizaccess/seb:manage_seb_usesebclientconfig' => [ 'captype' => 'read', 'contextlevel' => CONTEXT_MODULE, 'archetypes' => [ 'manager' => CAP_ALLOW, 'editingteacher' => CAP_ALLOW, ], 'clonepermissionsfrom' => 'quizaccess/seb:manage_seb_requiresafeexambrowser', ], 'quizaccess/seb:manage_seb_templateid' => [ 'captype' => 'read', 'contextlevel' => CONTEXT_MODULE, 'archetypes' => [ 'manager' => CAP_ALLOW, 'editingteacher' => CAP_ALLOW ] ], 'quizaccess/seb:manage_filemanager_sebconfigfile' => [ 'captype' => 'write', 'contextlevel' => CONTEXT_MODULE, 'archetypes' => [ 'manager' => CAP_ALLOW, 'editingteacher' => CAP_ALLOW ] ], 'quizaccess/seb:manage_seb_showsebdownloadlink' => [ 'captype' => 'write', 'contextlevel' => CONTEXT_MODULE, 'archetypes' => [ 'manager' => CAP_ALLOW, 'editingteacher' => CAP_ALLOW ] ], 'quizaccess/seb:manage_seb_allowedbrowserexamkeys' => [ 'captype' => 'write', 'contextlevel' => CONTEXT_MODULE, 'archetypes' => [ 'manager' => CAP_ALLOW, 'editingteacher' => CAP_ALLOW ] ], 'quizaccess/seb:manage_seb_linkquitseb' => [ 'captype' => 'write', 'contextlevel' => CONTEXT_MODULE, 'archetypes' => [ 'manager' => CAP_ALLOW, 'editingteacher' => CAP_ALLOW ] ], 'quizaccess/seb:manage_seb_userconfirmquit' => [ 'captype' => 'write', 'contextlevel' => CONTEXT_MODULE, 'archetypes' => [ 'manager' => CAP_ALLOW, 'editingteacher' => CAP_ALLOW ] ], 'quizaccess/seb:manage_seb_allowuserquitseb' => [ 'captype' => 'write', 'contextlevel' => CONTEXT_MODULE, 'archetypes' => [ 'manager' => CAP_ALLOW, 'editingteacher' => CAP_ALLOW ] ], 'quizaccess/seb:manage_seb_quitpassword' => [ 'captype' => 'write', 'contextlevel' => CONTEXT_MODULE, 'archetypes' => [ 'manager' => CAP_ALLOW, 'editingteacher' => CAP_ALLOW ] ], 'quizaccess/seb:manage_seb_allowreloadinexam' => [ 'captype' => 'write', 'contextlevel' => CONTEXT_MODULE, 'archetypes' => [ 'manager' => CAP_ALLOW, 'editingteacher' => CAP_ALLOW ] ], 'quizaccess/seb:manage_seb_showsebtaskbar' => [ 'captype' => 'write', 'contextlevel' => CONTEXT_MODULE, 'archetypes' => [ 'manager' => CAP_ALLOW, 'editingteacher' => CAP_ALLOW ] ], 'quizaccess/seb:manage_seb_showreloadbutton' => [ 'captype' => 'write', 'contextlevel' => CONTEXT_MODULE, 'archetypes' => [ 'manager' => CAP_ALLOW, 'editingteacher' => CAP_ALLOW ] ], 'quizaccess/seb:manage_seb_showtime' => [ 'captype' => 'write', 'contextlevel' => CONTEXT_MODULE, 'archetypes' => [ 'manager' => CAP_ALLOW, 'editingteacher' => CAP_ALLOW ] ], 'quizaccess/seb:manage_seb_showkeyboardlayout' => [ 'captype' => 'write', 'contextlevel' => CONTEXT_MODULE, 'archetypes' => [ 'manager' => CAP_ALLOW, 'editingteacher' => CAP_ALLOW ] ], 'quizaccess/seb:manage_seb_showwificontrol' => [ 'captype' => 'write', 'contextlevel' => CONTEXT_MODULE, 'archetypes' => [ 'manager' => CAP_ALLOW, 'editingteacher' => CAP_ALLOW ] ], 'quizaccess/seb:manage_seb_enableaudiocontrol' => [ 'captype' => 'write', 'contextlevel' => CONTEXT_MODULE, 'archetypes' => [ 'manager' => CAP_ALLOW, 'editingteacher' => CAP_ALLOW ] ], 'quizaccess/seb:manage_seb_muteonstartup' => [ 'captype' => 'write', 'contextlevel' => CONTEXT_MODULE, 'archetypes' => [ 'manager' => CAP_ALLOW, 'editingteacher' => CAP_ALLOW ] ], 'quizaccess/seb:manage_seb_allowcapturecamera' => [ 'captype' => 'write', 'contextlevel' => CONTEXT_MODULE, 'archetypes' => [ 'manager' => CAP_ALLOW, 'editingteacher' => CAP_ALLOW, ], ], 'quizaccess/seb:manage_seb_allowcapturemicrophone' => [ 'captype' => 'write', 'contextlevel' => CONTEXT_MODULE, 'archetypes' => [ 'manager' => CAP_ALLOW, 'editingteacher' => CAP_ALLOW, ], ], 'quizaccess/seb:manage_seb_allowspellchecking' => [ 'captype' => 'write', 'contextlevel' => CONTEXT_MODULE, 'archetypes' => [ 'manager' => CAP_ALLOW, 'editingteacher' => CAP_ALLOW ] ], 'quizaccess/seb:manage_seb_activateurlfiltering' => [ 'captype' => 'write', 'contextlevel' => CONTEXT_MODULE, 'archetypes' => [ 'manager' => CAP_ALLOW, 'editingteacher' => CAP_ALLOW ] ], 'quizaccess/seb:manage_seb_filterembeddedcontent' => [ 'captype' => 'write', 'contextlevel' => CONTEXT_MODULE, 'archetypes' => [ 'manager' => CAP_ALLOW, 'editingteacher' => CAP_ALLOW ] ], 'quizaccess/seb:manage_seb_expressionsallowed' => [ 'captype' => 'write', 'contextlevel' => CONTEXT_MODULE, 'archetypes' => [ 'manager' => CAP_ALLOW, 'editingteacher' => CAP_ALLOW ] ], 'quizaccess/seb:manage_seb_regexallowed' => [ 'captype' => 'write', 'contextlevel' => CONTEXT_MODULE, 'archetypes' => [ 'manager' => CAP_ALLOW, 'editingteacher' => CAP_ALLOW ] ], 'quizaccess/seb:manage_seb_expressionsblocked' => [ 'captype' => 'write', 'contextlevel' => CONTEXT_MODULE, 'archetypes' => [ 'manager' => CAP_ALLOW, 'editingteacher' => CAP_ALLOW ] ], 'quizaccess/seb:manage_seb_regexblocked' => [ 'captype' => 'write', 'contextlevel' => CONTEXT_MODULE, 'archetypes' => [ 'manager' => CAP_ALLOW, 'editingteacher' => CAP_ALLOW ] ], ]; PK9B\Yg' ' accessrule/seb/db/upgrade.phpnu[. /** * Upgrade script for plugin. * * @package quizaccess_seb * @author Andrew Madden * @copyright 2019 Catalyst IT * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); require_once($CFG->dirroot . '/mod/quiz/accessrule/seb/lib.php'); /** * Function to upgrade quizaccess_seb plugin. * * @param int $oldversion The version we are upgrading from. * @return bool Result. */ function xmldb_quizaccess_seb_upgrade($oldversion) { global $DB; $dbman = $DB->get_manager(); // 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. if ($oldversion < 2024121801) { // Define field allowcapturecamera to be added to quizaccess_seb_quizsettings. $table = new xmldb_table('quizaccess_seb_quizsettings'); $field = new xmldb_field('allowcapturecamera', XMLDB_TYPE_INTEGER, '1', null, null, null, null, 'muteonstartup'); // Conditionally launch add field allowcapturecamera. if (!$dbman->field_exists($table, $field)) { $dbman->add_field($table, $field); } // Define field allowcapturemicrophone to be added to quizaccess_seb_quizsettings. $field = new xmldb_field('allowcapturemicrophone', XMLDB_TYPE_INTEGER, '1', null, null, null, null, 'allowcapturecamera'); // Conditionally launch add field allowcapturemicrophone. if (!$dbman->field_exists($table, $field)) { $dbman->add_field($table, $field); } // Seb savepoint reached. upgrade_plugin_savepoint(true, 2024121801, 'quizaccess', 'seb'); } // Automatically generated Moodle v5.0.0 release upgrade line. // Put any upgrade step following this. return true; } PK9B\yәaccessrule/seb/db/install.xmlnu[
PK9B\wo Paccessrule/seb/db/caches.phpnu[. /** * Plugin cache definitions. * * @package quizaccess_seb * @author Dmitrii Metelkin * @copyright 2020 Catalyst IT * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); $definitions = [ 'quizsettings' => [ 'mode' => cache_store::MODE_APPLICATION, 'simplekeys' => true, 'simpledata' => true, 'staticacceleration' => true, ], 'config' => [ 'mode' => cache_store::MODE_APPLICATION, 'simplekeys' => true, 'simpledata' => true, 'staticacceleration' => true, ], 'configkey' => [ 'mode' => cache_store::MODE_APPLICATION, 'simplekeys' => true, 'simpledata' => true, 'staticacceleration' => true, ], ]; PK9B\MJJ)accessrule/seb/lang/en/quizaccess_seb.phpnu[. /** * Strings for the quizaccess_seb plugin. * * @package quizaccess_seb * @author Luca Bösch * @author Andrew Madden * @author Dmitrii Metelkin * @copyright 2019 Catalyst IT * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); $string['addtemplate'] = 'Add new template'; $string['allowedbrowserkeysdistinct'] = 'The keys must all be different.'; $string['allowedbrowserkeyssyntax'] = 'A key should be a 64-character hex string.'; $string['cachedef_config'] = 'SEB config cache'; $string['cachedef_configkey'] = 'SEB config key cache'; $string['cachedef_quizsettings'] = 'SEB quiz settings cache'; $string['cantdelete'] = 'The template can\'t be deleted as it has been used for one or more quizzes.'; $string['cantedit'] = 'The template can\'t be edited as it has been used for one or more quizzes.'; $string['checkingaccess'] = 'Checking access to Safe Exam Browser...'; $string['clientrequiresseb'] = 'This quiz has been configured to use the Safe Exam Browser with client configuration.'; $string['confirmtemplateremovalquestion'] = 'Are you sure you want to remove this template?'; $string['confirmtemplateremovaltitle'] = 'Confirm template removal?'; $string['conflictingsettings'] = 'You don\'t have permission to update existing Safe Exam Browser settings.'; $string['content'] = 'Template'; $string['description'] = 'Description'; $string['disabledsettings'] = 'Disabled settings.'; $string['disabledsettings_help'] = 'Safe Exam Browser quiz settings can\'t be changed if the quiz has been attempted. To change a setting, all quiz attempts must first be deleted.'; $string['downloadsebconfig'] = 'Download SEB config file'; $string['duplicatetemplate'] = 'A template with the same name already exists.'; $string['edittemplate'] = 'Edit template'; $string['enabled'] = 'Enabled'; $string['error:ws:nokeyprovided'] = 'At least one Safe Exam Browser key must be provided.'; $string['error:ws:quiznotexists'] = 'Quiz not found matching course module ID: {$a}'; $string['event:accessprevented'] = "Quiz access was prevented"; $string['event:templatecreated'] = 'SEB template was created'; $string['event:templatedeleted'] = 'SEB template was deleted'; $string['event:templatedisabled'] = 'SEB template was disabled'; $string['event:templateenabled'] = 'SEB template was enabled'; $string['event:templateupdated'] = 'SEB template was updated'; $string['exitsebbutton'] = 'Exit Safe Exam Browser'; $string['filemanager_sebconfigfile'] = 'Upload Safe Exam Browser config file'; $string['filemanager_sebconfigfile_help'] = 'Please upload your own Safe Exam Browser config file for this quiz.'; $string['filenotpresent'] = 'Please upload a SEB config file.'; $string['fileparsefailed'] = 'The uploaded file could not be saved as a SEB config file.'; $string['httplinkbutton'] = 'Download configuration'; $string['invalid_browser_key'] = "Invalid SEB browser key"; $string['invalid_config_key'] = "Invalid SEB config key"; $string['invalidkeys'] = 'The Safe Exam Browser keys could not be validated. Check that you\'re using Safe Exam Browser with the correct configuration file.'; $string['invalidtemplate'] = "Invalid SEB config template"; $string['manage_templates'] = 'Safe Exam Browser templates'; $string['managetemplates'] = 'Manage templates'; $string['missingrequiredsettings'] = 'Config settings are missing some required values.'; $string['name'] = 'Name'; $string['newtemplate'] = 'New template'; $string['noconfigfilefound'] = 'No uploaded SEB config file could be found for quiz with cmid: {$a}'; $string['noconfigfound'] = 'No SEB config could be found for quiz with cmid: {$a}'; $string['not_seb'] = 'No Safe Exam Browser is being used.'; $string['notemplate'] = 'No template'; $string['passwordnotset'] = 'Current settings require quizzes using the Safe Exam Browser to have a quiz password set.'; $string['pluginname'] = 'Safe Exam Browser access rules'; $string['privacy:metadata:quizaccess_seb_quizsettings'] = 'Safe Exam Browser settings for a quiz. This includes the ID of the last user to create or modify the settings.'; $string['privacy:metadata:quizaccess_seb_quizsettings:quizid'] = 'ID of the quiz the settings exist for.'; $string['privacy:metadata:quizaccess_seb_quizsettings:timecreated'] = 'Unix time that the settings were created.'; $string['privacy:metadata:quizaccess_seb_quizsettings:timemodified'] = 'Unix time that the settings were last modified.'; $string['privacy:metadata:quizaccess_seb_quizsettings:usermodified'] = 'ID of user who last created or modified the settings.'; $string['privacy:metadata:quizaccess_seb_template'] = 'Safe Exam Browser template settings. This includes the ID of the last user to create or modify the template.'; $string['privacy:metadata:quizaccess_seb_template:timecreated'] = 'Unix time that the template was created.'; $string['privacy:metadata:quizaccess_seb_template:timemodified'] = 'Unix time that the template was last modified.'; $string['privacy:metadata:quizaccess_seb_template:usermodified'] = 'ID of user who last created or modified the template.'; $string['quizsettings'] = 'Quiz settings'; $string['restoredfrom'] = '{$a->name} (restored via cmid {$a->cmid})'; $string['seb'] = 'Safe Exam Browser'; $string['seb:bypassseb'] = 'Bypass the requirement to view quiz in Safe Exam Browser.'; $string['seb:manage_filemanager_sebconfigfile'] = 'Change SEB quiz setting: Select SEB config file'; $string['seb:manage_seb_activateurlfiltering'] = 'Change SEB quiz setting: Activate URL filtering'; $string['seb:manage_seb_allowcapturecamera'] = 'Change SEB quiz setting: Allow browser access to camera'; $string['seb:manage_seb_allowcapturemicrophone'] = 'Change SEB quiz setting: Allow browser access to microphone'; $string['seb:manage_seb_allowedbrowserexamkeys'] = 'Change SEB quiz setting: Allowed browser exam keys'; $string['seb:manage_seb_allowreloadinexam'] = 'Change SEB quiz setting: Allow reload'; $string['seb:manage_seb_allowspellchecking'] = 'Change SEB quiz setting: Enable spell checking'; $string['seb:manage_seb_allowuserquitseb'] = 'Change SEB quiz setting: Allow quit'; $string['seb:manage_seb_configuremanually'] = 'Change SEB quiz setting: Select manual configuration'; $string['seb:manage_seb_enableaudiocontrol'] = 'Change SEB quiz setting: Enable audio control'; $string['seb:manage_seb_expressionsallowed'] = 'Change SEB quiz setting: Simple expressions allowed'; $string['seb:manage_seb_expressionsblocked'] = 'Change SEB quiz setting: Simple expressions blocked'; $string['seb:manage_seb_filterembeddedcontent'] = 'Change SEB quiz setting: Filter embedded content'; $string['seb:manage_seb_linkquitseb'] = 'Change SEB quiz setting: Quit link'; $string['seb:manage_seb_muteonstartup'] = 'Change SEB quiz setting: Mute on startup'; $string['seb:manage_seb_quitpassword'] = 'Change SEB quiz setting: Quit password'; $string['seb:manage_seb_regexallowed'] = 'Change SEB quiz setting: Regex expressions allowed'; $string['seb:manage_seb_regexblocked'] = 'Change SEB quiz setting: Regex expressions blocked'; $string['seb:manage_seb_requiresafeexambrowser'] = 'Change SEB quiz setting: Require Safe Exam Browser'; $string['seb:manage_seb_showkeyboardlayout'] = 'Change SEB quiz setting: Show keyboard layout'; $string['seb:manage_seb_showreloadbutton'] = 'Change SEB quiz setting: Show reload button'; $string['seb:manage_seb_showsebtaskbar'] = 'Change SEB quiz setting: Show task bar'; $string['seb:manage_seb_showtime'] = 'Change SEB quiz setting: Show time'; $string['seb:manage_seb_showwificontrol'] = 'Change SEB quiz setting: Show Wi-Fi control'; $string['seb:manage_seb_showsebdownloadlink'] = 'Change SEB quiz setting: Show download link'; $string['seb:manage_seb_templateid'] = 'Change SEB quiz setting: Select SEB template'; $string['seb:manage_seb_userconfirmquit'] = 'Change SEB quiz setting: Confirm on quit'; $string['seb:manage_seb_usesebclientconfig'] = 'Change SEB quiz setting: Use SEB client configuration'; $string['seb:managetemplates'] = 'Manage SEB configuration templates'; $string['seb_activateurlfiltering'] = 'Enable URL filtering'; $string['seb_activateurlfiltering_help'] = 'If enabled, URLs will be filtered when loading web pages. The filter set has to be defined below.'; $string['seb_allowcapturecamera'] = 'Allow browser access to camera'; $string['seb_allowcapturecamera_help'] = 'If enabled, users will be asked to grant access to the camera for SEB before the exam session is started. In macOS 12/iOS 15 or later, disabling this setting prevents web applications from accessing the camera i.e. video recording via WebRTC will not be possible.'; $string['seb_allowcapturemicrophone'] = 'Allow browser access to microphone'; $string['seb_allowcapturemicrophone_help'] = 'If enabled, users will be asked to grant access to the microphone for SEB before the exam session is started. In macOS 12/iOS 15 or later, disabling this setting prevents web applications from accessing the microphone i.e. audio recording via WebRTC will not be possible.'; $string['seb_allowedbrowserexamkeys'] = 'Allowed browser exam keys'; $string['seb_allowedbrowserexamkeys_help'] = 'In this field you can enter the allowed browser exam keys for versions of Safe Exam Browser that are permitted to access this quiz. If no keys are entered, then browser exam keys are not checked.'; $string['seb_allowreloadinexam'] = 'Enable reload in exam'; $string['seb_allowreloadinexam_help'] = 'If enabled, page reload is allowed (reload button in SEB task bar, browser tool bar, iOS side slider menu, keyboard shortcut F5/cmd+R). Note that offline caching may break if a user tries to reload a page without an internet connection.'; $string['seb_allowspellchecking'] = 'Enable spell checking'; $string['seb_allowspellchecking_help'] = 'If enabled, spell checking in the SEB browser is allowed.'; $string['seb_allowuserquitseb'] = 'Enable quitting of SEB'; $string['seb_allowuserquitseb_help'] = 'If enabled, users can quit SEB with the "Quit" button in the SEB task bar or by pressing the keys Ctrl-Q or by clicking the main browser window close button.'; $string['seb_enableaudiocontrol'] = 'Enable audio controls'; $string['seb_enableaudiocontrol_help'] = 'If enabled, the audio control icon is shown in the SEB task bar.'; $string['seb_expressionsallowed'] = 'Expressions allowed'; $string['seb_expressionsallowed_help'] = 'A text field which contains the allowed filtering expressions for the allowed URLs. Use of the wildcard char \'\*\' is possible. Examples for expressions: \'example.com\' or \'example.com/stuff/\*\'. \'example.com\' matches \'example.com\', \'www.example.com\' and \'www.mail.example.com\'. \'example.com/stuff/\*\' matches all requests to any subdomain of \'example.com\' that have \'stuff\' as the first segment of the path.'; $string['seb_expressionsblocked'] = 'Expressions blocked'; $string['seb_expressionsblocked_help'] = 'A text field which contains the filtering expressions for the blocked URLs. Use of the wildcard char \'\*\' is possible. Examples for expressions: \'example.com\' or \'example.com/stuff/\*\'. \'example.com\' matches \'example.com\', \'www.example.com\' and \'www.mail.example.com\'. \'example.com/stuff/\*\' matches all requests to any subdomain of \'example.com\' that have \'stuff\' as the first segment of the path.'; $string['seb_filterembeddedcontent'] = 'Filter also embedded content'; $string['seb_filterembeddedcontent_help'] = 'If enabled, embedded resources will also be filtered using the filter set.'; $string['seb_help'] = 'Setup quiz to use the Safe Exam Browser.'; $string['seb_linkquitseb'] = 'Show Exit Safe Exam Browser button, configured with this quit link'; $string['seb_linkquitseb_help'] = 'In this field you can enter the link to quit SEB. It is used on an "Exit Safe Exam Browser" button on the page that appears after the exam is submitted. When clicking the button or the link placed wherever you want to put it, it is possible to quit SEB without having to enter a quit password. For an HTTPS site, the quit link has to start with https://. If no link is entered, then the "Exit Safe Exam Browser" button does not appear and there is no link set to quit SEB.'; $string['seb_managetemplates'] = 'Manage Safe Exam Browser templates'; $string['seb_muteonstartup'] = 'Mute on startup'; $string['seb_muteonstartup_help'] = 'If enabled, audio is initially muted when starting SEB.'; $string['seb_quitpassword'] = 'Quit password'; $string['seb_quitpassword_help'] = 'This password is prompted when users try to quit SEB with the "Quit" button, Ctrl-Q or the close button in the main browser window. If no quit password is set, then SEB just prompts "Are you sure you want to quit SEB?".'; $string['seb_regexallowed'] = 'Regex allowed'; $string['seb_regexallowed_help'] = 'A text field which contains the filtering expressions for allowed URLs in a regular expression (Regex) format.'; $string['seb_regexblocked'] = 'Regex blocked'; $string['seb_regexblocked_help'] = 'A text field which contains the filtering expressions for blocked URLs in a regular expression (Regex) format.'; $string['seb_requiresafeexambrowser'] = 'Require the use of Safe Exam Browser'; $string['seb_requiresafeexambrowser_help'] = 'If enabled, students can only attempt the quiz using the Safe Exam Browser. The available options are: * No
Safe Exam Browser is not required to attempt the quiz. * Yes – Use an existing template
A template for the configuration of Safe Exam Browser can be used. Templates are managed in the site administration. Your manual settings overwrite the settings in the template. * Yes – Configure manually
No template for the configuration of Safe Exam Browser will be used. You can configure Safe Exam Browser manually. * Yes – Upload my own config
You can upload your own Safe Exam Browser configuration file. All manual settings and the use of templates will be disabled. * Yes – Use SEB client config
No configurations of Safe Exam Browser are on the Moodle side. The quiz can be attempted with any configuration of Safe Exam Browser.'; $string['seb_showkeyboardlayout'] = 'Show keyboard layout'; $string['seb_showkeyboardlayout_help'] = 'If enabled, the current keyboard layout is shown in the SEB task bar. It allows you to switch to other keyboard layouts, which have been enabled in the operating system.'; $string['seb_showreloadbutton'] = 'Show reload button'; $string['seb_showreloadbutton_help'] = 'If enabled, a reload button is displayed in the SEB task bar, allowing the current web page to be reloaded.'; $string['seb_showsebtaskbar'] = 'Show SEB task bar'; $string['seb_showsebtaskbar_help'] = 'If enabled, a task bar appears at the bottom of the SEB browser window. The task bar is required to display items such as Wi-Fi control, reload button, time and keyboard layout.'; $string['seb_showtime'] = 'Show time'; $string['seb_showtime_help'] = 'If enabled, the current time is displayed in the SEB task bar.'; $string['seb_showwificontrol'] = 'Show Wi-Fi control'; $string['seb_showwificontrol_help'] = 'If enabled, a Wi-Fi control button appears in the SEB task bar. The button allows users to reconnect to Wi-Fi networks which have previously been connected to.'; $string['seb_showsebdownloadlink'] = 'Show Safe Exam Browser download button'; $string['seb_showsebdownloadlink_help'] = 'If enabled, a button for Safe Exam Browser download will be shown on the quiz start page.'; $string['seb_templateid'] = 'Safe Exam Browser config template'; $string['seb_templateid_help'] = 'The settings in the selected config template will be used for the configuration of the Safe Exam Browser while attempting the quiz. You may overwrite the settings in the template with your manual settings.'; $string['seb_use_client'] = 'Yes – Use SEB client config'; $string['seb_use_manually'] = 'Yes – Configure manually'; $string['seb_use_template'] = 'Yes – Use an existing template'; $string['seb_use_upload'] = 'Yes – Upload my own config'; $string['seb_userconfirmquit'] = 'Ask user to confirm quitting'; $string['seb_userconfirmquit_help'] = 'If enabled, users have to confirm quitting of SEB when a quit link is detected.'; $string['sebdownloadbutton'] = 'Download Safe Exam Browser'; $string['sebkeysvalidationfailed'] = 'Error validating SEB keys'; $string['seblinkbutton'] = 'Launch Safe Exam Browser'; $string['sebrequired'] = "This quiz has been configured so that students may only attempt it using the Safe Exam Browser."; $string['setting:autoreconfigureseb'] = 'Auto-configure SEB'; $string['setting:autoreconfigureseb_desc'] = 'If enabled, users who navigate to the quiz using the Safe Exam Browser will be automatically forced to reconfigure their Safe Exam Browser.'; $string['setting:displayblocksbeforestart'] = 'Display blocks before starting quiz'; $string['setting:displayblocksbeforestart_desc'] = 'If enabled, blocks will be displayed before a user attempts the quiz.'; $string['setting:displayblockswhenfinished'] = 'Display blocks after finishing quiz'; $string['setting:displayblockswhenfinished_desc'] = 'If enabled, blocks will be displayed after a user has finished their quiz attempt.'; $string['setting:downloadlink'] = 'Safe Exam Browser download link'; $string['setting:downloadlink_desc'] = 'URL for downloading the Safe Exam Browser application.'; $string['setting:quizpasswordrequired'] = 'Quiz password required'; $string['setting:quizpasswordrequired_desc'] = 'If enabled, all quizzes that require the Safe Exam Browser must have a quiz password set.'; $string['setting:showhttplink'] = 'Show http:// link'; $string['setting:showseblink'] = 'Show seb:// link'; $string['setting:showseblinks'] = 'Show Safe Exam Browser config links'; $string['setting:showseblinks_desc'] = 'Whether to show links for a user to access the Safe Exam Browser configuration file when access to the quiz is prevented. Note that seb:// links may not work in every browser.'; $string['setting:supportedversions'] = 'Please note that the following minimum versions of the Safe Exam Browser client are required to use the config key feature: macOS - 2.1.5pre2, Windows - 3.0, iOS - 2.1.14.'; $string['settingsfrozen'] = 'Due to there being at least one quiz attempt, the Safe Exam Browser settings can no longer be updated.'; $string['unknown_reason'] = "Unknown reason"; $string['used'] = 'In use'; PK9B\kHaccessrule/seb/backup/moodle2/restore_quizaccess_seb_subplugin.class.phpnu[. /** * Restore instructions for the seb (Safe Exam Browser) quiz access subplugin. * * @package quizaccess_seb * @category backup * @author Andrew Madden * @copyright 2020 Catalyst IT * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ use quizaccess_seb\seb_quiz_settings; use quizaccess_seb\settings_provider; use quizaccess_seb\template; defined('MOODLE_INTERNAL') || die(); require_once($CFG->dirroot . '/mod/quiz/backup/moodle2/restore_mod_quiz_access_subplugin.class.php'); /** * Restore instructions for the seb (Safe Exam Browser) quiz access subplugin. * * @copyright 2020 Catalyst IT * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class restore_quizaccess_seb_subplugin extends restore_mod_quiz_access_subplugin { /** * Provides path structure required to restore data for seb quiz access plugin. * * @return array */ protected function define_quiz_subplugin_structure() { $paths = []; // Quiz settings. $path = $this->get_pathfor('/quizaccess_seb_quizsettings'); // Subplugin root path. $paths[] = new restore_path_element('quizaccess_seb_quizsettings', $path); // Template settings. $path = $this->get_pathfor('/quizaccess_seb_quizsettings/quizaccess_seb_template'); $paths[] = new restore_path_element('quizaccess_seb_template', $path); return $paths; } /** * Process the restored data for the quizaccess_seb_quizsettings table. * * @param stdClass $data Data for quizaccess_seb_quizsettings retrieved from backup xml. */ public function process_quizaccess_seb_quizsettings($data) { global $DB, $USER; // Process quizsettings. $data = (object) $data; $data->quizid = $this->get_new_parentid('quiz'); // Update quizid with new reference. $data->cmid = $this->task->get_moduleid(); unset($data->id); $data->timecreated = $data->timemodified = time(); $data->usermodified = $USER->id; // Do not use template if it is no longer enabled. if ($data->requiresafeexambrowser == settings_provider::USE_SEB_TEMPLATE && !$DB->record_exists(template::TABLE, ['id' => $data->templateid, 'enabled' => '1'])) { $data->templateid = 0; $data->requiresafeexambrowser = settings_provider::USE_SEB_NO; } $DB->insert_record(seb_quiz_settings::TABLE, $data); // Process attached files. $this->add_related_files('quizaccess_seb', 'filemanager_sebconfigfile', null); } /** * Process the restored data for the quizaccess_seb_template table. * * @param stdClass $data Data for quizaccess_seb_template retrieved from backup xml. */ public function process_quizaccess_seb_template($data) { global $DB; $data = (object) $data; $quizid = $this->get_new_parentid('quiz'); $template = null; if ($this->task->is_samesite()) { $template = \quizaccess_seb\template::get_record(['id' => $data->id]); } else { // In a different site, try to find existing template with the same name and content. $candidates = \quizaccess_seb\template::get_records(['name' => $data->name]); foreach ($candidates as $candidate) { if ($candidate->get('content') == $data->content) { $template = $candidate; break; } } } if (empty($template)) { unset($data->id); $template = new \quizaccess_seb\template(0, $data); $template->save(); } // Update the restored quiz settings to use restored template. // Check if template is enabled before using it. if ($template->get('enabled')) { $DB->set_field(seb_quiz_settings::TABLE, 'templateid', $template->get('id'), ['quizid' => $quizid]); } } } PK9B\%Y'Gaccessrule/seb/backup/moodle2/backup_quizaccess_seb_subplugin.class.phpnu[. /** * Backup instructions for the seb (Safe Exam Browser) quiz access subplugin. * * @package quizaccess_seb * @category backup * @author Andrew Madden * @copyright 2020 Catalyst IT * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); require_once($CFG->dirroot . '/mod/quiz/backup/moodle2/backup_mod_quiz_access_subplugin.class.php'); /** * Backup instructions for the seb (Safe Exam Browser) quiz access subplugin. * * @copyright 2020 Catalyst IT * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class backup_quizaccess_seb_subplugin extends backup_mod_quiz_access_subplugin { /** * Stores the data related to the Safe Exam Browser quiz settings and management for a particular quiz. * * @return backup_subplugin_element */ protected function define_quiz_subplugin_structure() { parent::define_quiz_subplugin_structure(); $quizid = backup::VAR_ACTIVITYID; $subplugin = $this->get_subplugin_element(); $subpluginwrapper = new backup_nested_element($this->get_recommended_name()); $template = new \quizaccess_seb\template(); $blanktemplatearray = (array) $template->to_record(); unset($blanktemplatearray['usermodified']); unset($blanktemplatearray['timemodified']); $templatekeys = array_keys($blanktemplatearray); $subplugintemplatesettings = new backup_nested_element('quizaccess_seb_template', null, $templatekeys); // Get quiz settings keys to save. $settings = new \quizaccess_seb\seb_quiz_settings(); $blanksettingsarray = (array) $settings->to_record(); unset($blanksettingsarray['id']); // We don't need to save reference to settings record in current instance. // We don't need to save the data about who last modified the settings as they will be overwritten on restore. Also // means we don't have to think about user data for the backup. unset($blanksettingsarray['usermodified']); unset($blanksettingsarray['timemodified']); $settingskeys = array_keys($blanksettingsarray); // Save the settings. $subpluginquizsettings = new backup_nested_element('quizaccess_seb_quizsettings', null, $settingskeys); // Connect XML elements into the tree. $subplugin->add_child($subpluginwrapper); $subpluginwrapper->add_child($subpluginquizsettings); $subpluginquizsettings->add_child($subplugintemplatesettings); // Set source to populate the settings data by referencing the ID of quiz being backed up. $subpluginquizsettings->set_source_table(quizaccess_seb\seb_quiz_settings::TABLE, ['quizid' => $quizid]); $subpluginquizsettings->annotate_files('quizaccess_seb', 'filemanager_sebconfigfile', null); $params = ['id' => '../templateid']; $subplugintemplatesettings->set_source_table(\quizaccess_seb\template::TABLE, $params); return $subplugin; } } PK9B\4accessrule/seb/config.phpnu[. /** * Serves an encrypted/unencrypted string as a file for download. * * @package quizaccess_seb * @author Andrew Madden * @copyright 2019 Catalyst IT * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ require_once(__DIR__ . '/../../../../config.php'); $cmid = required_param('cmid', PARAM_RAW); $config = \quizaccess_seb\helper::get_seb_config_content($cmid); \quizaccess_seb\helper::send_seb_config_file($config); PK9B\*#{M M accessrule/seb/lib.phpnu[. /** * Main library of plugin. * * @package quizaccess_seb * @author Andrew Madden * @author Dmitrii Metelkin * @copyright 2019 Catalyst IT * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); /** * Serve the files. * * @param stdClass $course the course object * @param stdClass $cm the course module object * @param \context $context the context * @param string $filearea the name of the file area * @param array $args extra arguments (itemid, path) * @param bool $forcedownload whether or not force download * @param array $options additional options affecting the file serving * @return bool false if the file not found, just send the file otherwise and do not return anything */ function quizaccess_seb_pluginfile($course, $cm, $context, $filearea, $args, $forcedownload, array $options= []) { if ($context->contextlevel != CONTEXT_MODULE) { return false; } if ($filearea !== 'filemanager_sebconfigfile') { return false; } require_login($course, true, $cm); if (!has_capability('quizaccess/seb:manage_filemanager_sebconfigfile', $context)) { return false; } $itemid = array_shift($args); $filename = array_pop($args); if (!$args) { $filepath = '/'; } else { $filepath = '/' .implode('/', $args) . '/'; } $fs = get_file_storage(); $file = $fs->get_file($context->id, 'quizaccess_seb', $filearea, $itemid, $filepath, $filename); if (!$file) { return false; } send_stored_file($file, 0, 0, $forcedownload, $options); } PK9B\y+#ggaccessrule/seb/version.phpnu[. /** * Version information for the quizaccess_seb plugin. * * @package quizaccess_seb * @author Andrew Madden * @copyright 2019 Catalyst IT * @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 = 'quizaccess_seb'; $plugin->maturity = MATURITY_STABLE; PK9B\{i6accessrule/seb/classes/external/validate_quiz_keys.phpnu[. namespace quizaccess_seb\external; defined('MOODLE_INTERNAL') || die(); global $CFG; use core_external\external_api; use core_external\external_function_parameters; use core_external\external_single_structure; use core_external\external_value; use invalid_parameter_exception; use mod_quiz\quiz_settings; use quizaccess_seb\event\access_prevented; use quizaccess_seb\seb_access_manager; /** * Validate browser exam key and config key. * * @package quizaccess_seb * @author Andrew Madden * @copyright 2021 Catalyst IT * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class validate_quiz_keys extends external_api { /** * External function parameters. * * @return external_function_parameters */ public static function execute_parameters(): external_function_parameters { return new external_function_parameters([ 'cmid' => new external_value(PARAM_INT, 'Course module ID', VALUE_REQUIRED, null, NULL_NOT_ALLOWED), 'url' => new external_value(PARAM_URL, 'Page URL to check', VALUE_REQUIRED, null, NULL_NOT_ALLOWED), 'configkey' => new external_value(PARAM_ALPHANUMEXT, 'SEB config key', VALUE_DEFAULT, null), 'browserexamkey' => new external_value(PARAM_ALPHANUMEXT, 'SEB browser exam key', VALUE_DEFAULT, null), ]); } /** * Validate a SEB config key or browser exam key. * * @param string $cmid Course module ID. * @param string $url URL of the page on which the SEB JS API generated the keys. * @param string|null $configkey A SEB config key hash. Includes URL in the hash. * @param string|null $browserexamkey A SEB browser exam key hash. Includes the URL in the hash. * @return array */ public static function execute(string $cmid, string $url, ?string $configkey = null, ?string $browserexamkey = null): array { list( 'cmid' => $cmid, 'url' => $url, 'configkey' => $configkey, 'browserexamkey' => $browserexamkey ) = self::validate_parameters(self::execute_parameters(), [ 'cmid' => $cmid, 'url' => $url, 'configkey' => $configkey, 'browserexamkey' => $browserexamkey, ]); self::validate_context(\context_module::instance($cmid)); // At least one SEB key must be provided. if (empty($configkey) && empty($browserexamkey)) { throw new invalid_parameter_exception(get_string('error:ws:nokeyprovided', 'quizaccess_seb')); } // Check quiz exists corresponding to cmid. if (($quizid = self::get_quiz_id($cmid)) === 0) { throw new invalid_parameter_exception(get_string('error:ws:quiznotexists', 'quizaccess_seb', $cmid)); } $result = ['configkey' => true, 'browserexamkey' => true]; $accessmanager = new seb_access_manager(quiz_settings::create($quizid)); // Check if there is a valid config key. if (!$accessmanager->validate_config_key($configkey, $url)) { access_prevented::create_strict($accessmanager, get_string('invalid_config_key', 'quizaccess_seb'), $configkey, $browserexamkey)->trigger(); $result['configkey'] = false; } // Check if there is a valid browser exam key. if (!$accessmanager->validate_browser_exam_key($browserexamkey, $url)) { access_prevented::create_strict($accessmanager, get_string('invalid_browser_key', 'quizaccess_seb'), $configkey, $browserexamkey)->trigger(); $result['browserexamkey'] = false; } if ($result['configkey'] && $result['browserexamkey']) { // Set the state of the access for this Moodle session. $accessmanager->set_session_access(true); } return $result; } /** * External function returns. * * @return external_single_structure */ public static function execute_returns(): external_single_structure { return new external_single_structure([ 'configkey' => new external_value(PARAM_BOOL, 'Is a provided config key valid?', VALUE_REQUIRED, 0, NULL_NOT_ALLOWED), 'browserexamkey' => new external_value(PARAM_BOOL, 'Is a provided browser exam key valid?', VALUE_REQUIRED, 0, NULL_NOT_ALLOWED) ]); } /** * Check if there is a valid quiz corresponding to a course module it. * * @param string $cmid Course module ID. * @return int Returns quiz id if cmid matches valid quiz, or 0 if there is no match. */ private static function get_quiz_id(string $cmid): int { $quizid = 0; $coursemodule = get_coursemodule_from_id('quiz', $cmid); if (!empty($coursemodule)) { $quizid = $coursemodule->instance; } return $quizid; } } PK9B\|4..+accessrule/seb/classes/privacy/provider.phpnu[. /** * Privacy Subsystem implementation for quizaccess_seb. * * @package quizaccess_seb * @author Andrew Madden * @copyright 2019 Catalyst IT * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace quizaccess_seb\privacy; use context; 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; use quizaccess_seb\seb_quiz_settings; use quizaccess_seb\template; defined('MOODLE_INTERNAL') || die(); /** * Privacy Subsystem implementation for quizaccess_seb. * * @copyright 2020 Catalyst IT * @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\core_userlist_provider, \core_privacy\local\request\plugin\provider { /** * Retrieve the user metadata stored by plugin. * * @param collection $collection Collection of metadata. * @return collection Collection of metadata. */ public static function get_metadata(collection $collection): collection { $collection->add_database_table( 'quizaccess_seb_quizsettings', [ 'quizid' => 'privacy:metadata:quizaccess_seb_quizsettings:quizid', 'usermodified' => 'privacy:metadata:quizaccess_seb_quizsettings:usermodified', 'timecreated' => 'privacy:metadata:quizaccess_seb_quizsettings:timecreated', 'timemodified' => 'privacy:metadata:quizaccess_seb_quizsettings:timemodified', ], 'privacy:metadata:quizaccess_seb_quizsettings' ); $collection->add_database_table( 'quizaccess_seb_template', [ 'usermodified' => 'privacy:metadata:quizaccess_seb_template:usermodified', 'timecreated' => 'privacy:metadata:quizaccess_seb_template:timecreated', 'timemodified' => 'privacy:metadata:quizaccess_seb_template:timemodified', ], 'privacy:metadata:quizaccess_seb_template' ); return $collection; } /** * Get the list of contexts that contain user information for the specified user. * * @param int $userid The user to search. * @return contextlist A list of contexts used in this plugin. */ public static function get_contexts_for_userid(int $userid): contextlist { $contextlist = new contextlist(); // The data is associated at the module context level, so retrieve the quiz context id. $sql = "SELECT c.id FROM {quizaccess_seb_quizsettings} qs JOIN {course_modules} cm ON cm.instance = qs.quizid JOIN {modules} m ON cm.module = m.id AND m.name = :modulename JOIN {context} c ON c.instanceid = cm.id AND c.contextlevel = :context WHERE qs.usermodified = :userid GROUP BY c.id"; $params = [ 'context' => CONTEXT_MODULE, 'modulename' => 'quiz', 'userid' => $userid ]; $contextlist->add_from_sql($sql, $params); $sql = "SELECT c.id FROM {quizaccess_seb_template} tem JOIN {quizaccess_seb_quizsettings} qs ON qs.templateid = tem.id JOIN {course_modules} cm ON cm.instance = qs.quizid JOIN {modules} m ON cm.module = m.id AND m.name = :modulename JOIN {context} c ON c.instanceid = cm.id AND c.contextlevel = :context WHERE qs.usermodified = :userid GROUP BY c.id"; $contextlist->add_from_sql($sql, $params); return $contextlist; } /** * 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 $DB; // Get all cmids that correspond to the contexts for a user. $cmids = []; foreach ($contextlist->get_contexts() as $context) { if ($context->contextlevel === CONTEXT_MODULE) { $cmids[] = $context->instanceid; } } // Do nothing if no matching quiz settings are found for the user. if (empty($cmids)) { return; } list($insql, $params) = $DB->get_in_or_equal($cmids, SQL_PARAMS_NAMED); $params['modulename'] = 'quiz'; // SEB quiz settings. $sql = "SELECT qs.id as id, qs.quizid as quizid, qs.usermodified as usermodified, qs.timecreated as timecreated, qs.timemodified as timemodified FROM {quizaccess_seb_quizsettings} qs JOIN {course_modules} cm ON cm.instance = qs.quizid JOIN {modules} m ON cm.module = m.id AND m.name = :modulename WHERE cm.id {$insql}"; $quizsettingslist = $DB->get_records_sql($sql, $params); $index = 0; foreach ($quizsettingslist as $quizsettings) { // Data export is organised in: {Context}/{Plugin Name}/{Table name}/{index}/data.json. $index++; $subcontext = [ get_string('pluginname', 'quizaccess_seb'), seb_quiz_settings::TABLE, $index ]; $data = (object) [ 'quizid' => $quizsettings->quizid, 'usermodified' => $quizsettings->usermodified, 'timecreated' => transform::datetime($quizsettings->timecreated), 'timemodified' => transform::datetime($quizsettings->timemodified) ]; writer::with_context($context)->export_data($subcontext, $data); } // SEB template settings. $sql = "SELECT tem.id as id, qs.quizid as quizid, tem.usermodified as usermodified, tem.timecreated as timecreated, tem.timemodified as timemodified FROM {quizaccess_seb_template} tem JOIN {quizaccess_seb_quizsettings} qs ON qs.templateid = tem.id JOIN {course_modules} cm ON cm.instance = qs.quizid JOIN {modules} m ON cm.module = m.id AND m.name = :modulename WHERE cm.id {$insql}"; $templatesettingslist = $DB->get_records_sql($sql, $params); $index = 0; foreach ($templatesettingslist as $templatesetting) { // Data export is organised in: {Context}/{Plugin Name}/{Table name}/{index}/data.json. $index++; $subcontext = [ get_string('pluginname', 'quizaccess_seb'), template::TABLE, $index ]; $data = (object) [ 'templateid' => $templatesetting->id, 'quizid' => $templatesetting->quizid, 'usermodified' => $templatesetting->usermodified, 'timecreated' => transform::datetime($templatesetting->timecreated), 'timemodified' => transform::datetime($templatesetting->timemodified) ]; writer::with_context($context)->export_data($subcontext, $data); } } /** * Delete all data for all users in the specified context. * * @param context $context The specific context to delete data for. */ public static function delete_data_for_all_users_in_context(\context $context) { global $DB; // Sanity check that context is at the module context level, then get the quizid. if ($context->contextlevel !== CONTEXT_MODULE) { return; } $cmid = $context->instanceid; $quizid = $DB->get_field('course_modules', 'instance', ['id' => $cmid]); $params['quizid'] = $quizid; $select = "id IN (SELECT templateid FROM {quizaccess_seb_quizsettings} qs WHERE qs.quizid = :quizid)"; $DB->set_field_select('quizaccess_seb_quizsettings', 'usermodified', 0, "quizid = :quizid", $params); $DB->set_field_select('quizaccess_seb_template', 'usermodified', 0, $select, $params); } /** * 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; // If the user has data, then only the User context should be present so get the first context. $contexts = $contextlist->get_contexts(); if (count($contexts) == 0) { return; } $params['usermodified'] = $contextlist->get_user()->id; $DB->set_field_select('quizaccess_seb_quizsettings', 'usermodified', 0, "usermodified = :usermodified", $params); $DB->set_field_select('quizaccess_seb_template', 'usermodified', 0, "usermodified = :usermodified", $params); } /** * 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(); if (!$context instanceof \context_module) { return; } // The data is associated at the quiz module context level, so retrieve the user's context id. $sql = "SELECT qs.usermodified AS userid FROM {quizaccess_seb_quizsettings} qs JOIN {course_modules} cm ON cm.instance = qs.quizid JOIN {modules} m ON cm.module = m.id AND m.name = ? WHERE cm.id = ?"; $params = ['quiz', $context->instanceid]; $userlist->add_from_sql('userid', $sql, $params); } /** * 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; $context = $userlist->get_context(); // Sanity check that context is at the Module context level. if ($context->contextlevel !== CONTEXT_MODULE) { return; } $userids = $userlist->get_userids(); list($insql, $inparams) = $DB->get_in_or_equal($userids, SQL_PARAMS_NAMED); $DB->set_field_select('quizaccess_seb_quizsettings', 'usermodified', 0, "usermodified {$insql}", $inparams); $DB->set_field_select('quizaccess_seb_template', 'usermodified', 0, "usermodified {$insql}", $inparams); } } PK9B\;u@,,.accessrule/seb/classes/local/form/template.phpnu[. /** * Form for manipulating with the template records. * * @package quizaccess_seb * @author Dmitrii Metelkin * @copyright 2020 Catalyst IT * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace quizaccess_seb\local\form; defined('MOODLE_INTERNAL') || die(); /** * Form for manipulating with the template records. * * @copyright 2020 Catalyst IT * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class template extends \core\form\persistent { /** @var string Persistent class name. */ protected static $persistentclass = 'quizaccess_seb\\template'; /** * Form definition. */ protected function definition() { $mform = $this->_form; $mform->addElement('text', 'name', get_string('name', 'quizaccess_seb')); $mform->addRule('name', get_string('required'), 'required', null, 'client'); $mform->setType('name', PARAM_TEXT); $mform->addElement('textarea', 'description', get_string('description', 'quizaccess_seb')); $mform->setType('description', PARAM_TEXT); if ($this->get_persistent()->get('id')) { $mform->addElement('textarea', 'content', get_string('content', 'quizaccess_seb'), ['rows' => 20, 'cols' => 60]); $mform->addRule('content', get_string('required'), 'required'); } else { $mform->addElement('filepicker', 'content', get_string('content', 'quizaccess_seb')); $mform->addRule('content', get_string('required'), 'required'); } $mform->addElement('selectyesno', 'enabled', get_string('enabled', 'quizaccess_seb')); $mform->setType('enabled', PARAM_INT); if ($this->get_persistent()->get('id')) { $mform->hardFreezeAllVisibleExcept(['enabled']); } $this->add_action_buttons(); } /** * Filter out the foreign fields of the persistent. * * @param \stdClass $data The data to filter the fields out of. * @return \stdClass. */ protected function filter_data_for_persistent($data) { // Uploading a new template file. if (empty($this->get_persistent()->get('id'))) { $files = $this->get_draft_files('content'); if ($files) { $file = reset($files); $data->content = $file->get_content(); } else { // No file found. Remove content data and let persistent to return an error. unset($data->content); } } return parent::filter_data_for_persistent($data); } /** * Extra validation. * * @param \stdClass $data Data to validate. * @param array $files Array of files. * @param array $errors Currently reported errors. * @return array of additional errors, or overridden errors. */ protected function extra_validation($data, $files, array &$errors) { $newerrors = []; // Check name. if (empty($data->name)) { $newerrors['name'] = get_string('namerequired', 'quizaccess_seb'); } return $newerrors; } } PK9B\M%4accessrule/seb/classes/local/table/template_list.phpnu[. /** * Templates table. * * @package quizaccess_seb * @author Dmitrii Metelkin * @copyright 2020 Catalyst IT * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace quizaccess_seb\local\table; use quizaccess_seb\helper; use quizaccess_seb\template; use quizaccess_seb\template_controller; defined('MOODLE_INTERNAL') || die(); require_once($CFG->libdir.'/tablelib.php'); /** * Templates table. * * @copyright 2020 Catalyst IT * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class template_list extends \flexible_table { /** * @var int Autogenerated id. */ private static $autoid = 0; /** * Constructor * * @param string|null $id to be used by the table, autogenerated if null. */ public function __construct($id = null) { global $PAGE; $id = (is_null($id) ? self::$autoid++ : $id); parent::__construct('quizaccess_seb' . $id); $this->define_baseurl($PAGE->url); $this->set_attribute('class', 'generaltable admintable'); // Column definition. $this->define_columns([ 'name', 'description', 'enabled', 'used', 'actions', ]); $this->define_headers([ get_string('name', 'quizaccess_seb'), get_string('description', 'quizaccess_seb'), get_string('enabled', 'quizaccess_seb'), get_string('used', 'quizaccess_seb'), get_string('actions'), ]); $this->setup(); } /** * Display name column. * * @param \quizaccess_seb\template $data Template for this row. * @return string */ protected function col_name(template $data): string { return \html_writer::link( new \moodle_url(template_controller::get_base_url(), [ 'id' => $data->get('id'), 'action' => template_controller::ACTION_EDIT, ]), $data->get('name') ); } /** * Display description column. * * @param \quizaccess_seb\template $data Template for this row. * @return string */ protected function col_description(template $data): string { return $data->get('description'); } /** * Display enabled column. * * @param \quizaccess_seb\template $data Template for this row. * @return string */ protected function col_enabled(template $data): string { return empty($data->get('enabled')) ? get_string('no') : get_string('yes'); } /** * Display if a template is being used. * * @param \quizaccess_seb\template $data Template for this row. * @return string */ protected function col_used(template $data): string { return $data->can_delete() ? get_string('no') : get_string('yes'); } /** * Display actions column. * * @param \quizaccess_seb\template $data Template for this row. * @return string */ protected function col_actions(template $data): string { $actions = []; $actions[] = helper::format_icon_link( new \moodle_url(template_controller::get_base_url(), [ 'id' => $data->get('id'), 'action' => template_controller::ACTION_EDIT, ]), 't/edit', get_string('edit') ); $actions[] = helper::format_icon_link( new \moodle_url(template_controller::get_base_url(), [ 'id' => $data->get('id'), 'action' => template_controller::ACTION_DELETE, 'sesskey' => sesskey(), ]), 't/delete', get_string('delete'), null, [ 'data-action' => 'delete', 'data-id' => $data->get('id'), ] ); return implode(' ', $actions); } /** * Sets the data of the table. * * @param \quizaccess_seb\template[] $records An array with records. */ public function display(array $records) { foreach ($records as $record) { $this->add_data_keyed($this->format_row($record)); } $this->finish_output(); } } PK9B\aD+(+(.accessrule/seb/classes/template_controller.phpnu[. /** * Class for manipulating with the template records. * * @package quizaccess_seb * @author Dmitrii Metelkin * @copyright 2020 Catalyst IT * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace quizaccess_seb; use core\notification; use quizaccess_seb\local\table\template_list; defined('MOODLE_INTERNAL') || die(); /** * Class for manipulating with the template records. * * @copyright 2020 Catalyst IT * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class template_controller { /** * View action. */ const ACTION_VIEW = 'view'; /** * Add action. */ const ACTION_ADD = 'add'; /** * Edit action. */ const ACTION_EDIT = 'edit'; /** * Delete action. */ const ACTION_DELETE = 'delete'; /** * Hide action. */ const ACTION_HIDE = 'hide'; /** * Show action. */ const ACTION_SHOW = 'show'; /** * Locally cached $OUTPUT object. * @var \bootstrap_renderer */ protected $output; /** * region_manager constructor. */ public function __construct() { global $OUTPUT; $this->output = $OUTPUT; } /** * Execute required action. * * @param string $action Action to execute. */ public function execute($action) { $this->set_external_page(); switch($action) { case self::ACTION_ADD: case self::ACTION_EDIT: $this->edit($action, optional_param('id', null, PARAM_INT)); break; case self::ACTION_DELETE: $this->delete(required_param('id', PARAM_INT)); break; case self::ACTION_HIDE: $this->hide(required_param('id', PARAM_INT)); break; case self::ACTION_SHOW: $this->show(required_param('id', PARAM_INT)); break; case self::ACTION_VIEW: default: $this->view(); break; } } /** * Set external page for the manager. */ protected function set_external_page() { admin_externalpage_setup('quizaccess_seb/template'); } /** * Return record instance. * * @param int $id * @param \stdClass|null $data * * @return \quizaccess_seb\template */ protected function get_instance($id = 0, ?\stdClass $data = null) { return new template($id, $data); } /** * Print out all records in a table. */ protected function display_all_records() { $records = template::get_records([], 'id'); $table = new template_list(); $table->display($records); } /** * Returns a text for create new record button. * @return string */ protected function get_create_button_text(): string { return get_string('addtemplate', 'quizaccess_seb'); } /** * Returns form for the record. * * @param \quizaccess_seb\template|null $instance * * @return \quizaccess_seb\local\form\template */ protected function get_form($instance): \quizaccess_seb\local\form\template { global $PAGE; return new \quizaccess_seb\local\form\template($PAGE->url->out(false), ['persistent' => $instance]); } /** * View page heading string. * @return string */ protected function get_view_heading(): string { return get_string('managetemplates', 'quizaccess_seb'); } /** * New record heading string. * @return string */ protected function get_new_heading(): string { return get_string('newtemplate', 'quizaccess_seb'); } /** * Edit record heading string. * @return string */ protected function get_edit_heading(): string { return get_string('edittemplate', 'quizaccess_seb'); } /** * Returns base URL for the manager. * @return string */ public static function get_base_url(): string { return '/mod/quiz/accessrule/seb/template.php'; } /** * Execute edit action. * * @param string $action Could be edit or create. * @param null|int $id Id of the region or null if creating a new one. */ protected function edit($action, $id = null) { global $PAGE; $PAGE->set_url(new \moodle_url(static::get_base_url(), ['action' => $action, 'id' => $id])); $instance = null; if ($id) { $instance = $this->get_instance($id); } $form = $this->get_form($instance); if ($form->is_cancelled()) { redirect(new \moodle_url(static::get_base_url())); } else if ($data = $form->get_data()) { unset($data->submitbutton); try { if (empty($data->id)) { $data->content = $form->get_file_content('content'); $persistent = $this->get_instance(0, $data); $persistent->create(); \quizaccess_seb\event\template_created::create_strict( $persistent, \context_system::instance() )->trigger(); $this->trigger_enabled_event($persistent); } else { $instance->from_record($data); $instance->update(); \quizaccess_seb\event\template_updated::create_strict( $instance, \context_system::instance() )->trigger(); $this->trigger_enabled_event($instance); } notification::success(get_string('changessaved')); } catch (\Exception $e) { notification::error($e->getMessage()); } redirect(new \moodle_url(static::get_base_url())); } else { if (empty($instance)) { $this->header($this->get_new_heading()); } else { if (!$instance->can_delete()) { notification::warning(get_string('cantedit', 'quizaccess_seb')); } $this->header($this->get_edit_heading()); } } $form->display(); $this->footer(); } /** * Execute delete action. * * @param int $id ID of the region. */ protected function delete($id) { require_sesskey(); $instance = $this->get_instance($id); if ($instance->can_delete()) { $instance->delete(); notification::success(get_string('deleted')); \quizaccess_seb\event\template_deleted::create_strict( $id, \context_system::instance() )->trigger(); redirect(new \moodle_url(static::get_base_url())); } else { notification::warning(get_string('cantdelete', 'quizaccess_seb')); redirect(new \moodle_url(static::get_base_url())); } } /** * Execute view action. */ protected function view() { global $PAGE; $this->header($this->get_view_heading()); $this->print_add_button(); $this->display_all_records(); // JS for Template management. $PAGE->requires->js_call_amd('quizaccess_seb/managetemplates', 'setup'); $this->footer(); } /** * Show the template. * * @param int $id The ID of the template to show. */ protected function show(int $id) { $this->show_hide($id, 1); } /** * Hide the template. * * @param int $id The ID of the template to hide. */ protected function hide($id) { $this->show_hide($id, 0); } /** * Show or Hide the template. * * @param int $id The ID of the template to hide. * @param int $visibility The intended visibility. */ protected function show_hide(int $id, int $visibility) { require_sesskey(); $template = $this->get_instance($id); $template->set('enabled', $visibility); $template->save(); $this->trigger_enabled_event($template); redirect(new \moodle_url(self::get_base_url())); } /** * Print out add button. */ protected function print_add_button() { echo $this->output->single_button( new \moodle_url(static::get_base_url(), ['action' => self::ACTION_ADD]), $this->get_create_button_text() ); } /** * Print out page header. * @param string $title Title to display. */ protected function header($title) { echo $this->output->header(); echo $this->output->heading($title); } /** * Print out the page footer. * * @return void */ protected function footer() { echo $this->output->footer(); } /** * Helper function to fire off an event that informs of if a template is enabled or not. * * @param template $template The template persistent object. */ private function trigger_enabled_event(template $template) { $eventstring = ($template->get('enabled') == 0 ? 'disabled' : 'enabled'); $func = '\quizaccess_seb\event\template_' . $eventstring; $func::create_strict( $template, \context_system::instance() )->trigger(); } }PK9B\d &accessrule/seb/classes/hideif_rule.phpnu[. /** * Class to store data for "hide if" rules for the settings form. * * @package quizaccess_seb * @author Dmitrii Metelkin * @copyright 2019 Catalyst IT * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace quizaccess_seb; defined('MOODLE_INTERNAL') || die(); /** * Class to store data for "hide if" rules for the settings form. * * @copyright 2020 Catalyst IT * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class hideif_rule { /** * Name of the element to hide. * @var string */ protected $element; /** * Name of the element that $element is dependant on. * @var string */ protected $dependantname; /** * Condition. E.g. 'eq', 'noteq' and etc. * @var string */ protected $condition; /** * Value to check the $condition against. * @var string */ protected $dependantvalue; /** * Constructor. * * @param string $element Name of the element to hide. * @param string $dependantname Name of the element that $element is dependant on. * @param string $condition Condition. E.g. 'eq', 'noteq' and etc. * @param string $dependantvalue Value to check the $condition against. */ public function __construct(string $element, string $dependantname, string $condition, string $dependantvalue) { $this->element = $element; $this->dependantname = $dependantname; $this->condition = $condition; $this->dependantvalue = $dependantvalue; } /** * Return name of the element to hide. * @return string */ public function get_element(): string { return $this->element; } /** * Returns name of the element that $element is dependant on. * @return string */ public function get_dependantname(): string { return $this->dependantname; } /** * Returns condition. E.g. 'eq', 'noteq' and etc * @return string */ public function get_condition(): string { return $this->condition; } /** * Returns value to check the $condition against. * @return string */ public function get_dependantvalue(): string { return $this->dependantvalue; } } PK9B\nb(WW!accessrule/seb/classes/helper.phpnu[. /** * Helper class. * * @package quizaccess_seb * @author Dmitrii Metelkin * @copyright 2020 Catalyst IT * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace quizaccess_seb; use CFPropertyList\CFPropertyList; defined('MOODLE_INTERNAL') || die(); /** * Helper class. * * @copyright 2020 Catalyst IT * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class helper { /** * Get a filler icon for display in the actions column of a table. * * @param string $url The URL for the icon. * @param string $icon The icon identifier. * @param string $alt The alt text for the icon. * @param string $iconcomponent The icon component. * @param array $options Display options. * @return string */ public static function format_icon_link($url, $icon, $alt, $iconcomponent = 'moodle', $options = []) { global $OUTPUT; return $OUTPUT->action_icon( $url, new \pix_icon($icon, $alt, $iconcomponent, [ 'title' => $alt, ]), null, $options ); } /** * Validate seb config string. * * @param string $sebconfig * @return bool */ public static function is_valid_seb_config(string $sebconfig): bool { $result = true; set_error_handler(function($errno, $errstr, $errfile, $errline ){ throw new \ErrorException($errstr, $errno, 0, $errfile, $errline); }); $plist = new CFPropertyList(); try { $plist->parse($sebconfig); } catch (\ErrorException $e) { $result = false; } catch (\Exception $e) { $result = false; } restore_error_handler(); return $result; } /** * A helper function to get a list of seb config file headers. * * @param int|null $expiretime Unix timestamp * @return array */ public static function get_seb_file_headers(?int $expiretime = null): array { if (is_null($expiretime)) { $expiretime = time(); } $headers = []; $headers[] = 'Cache-Control: private, max-age=1, no-transform'; $headers[] = 'Expires: '. gmdate('D, d M Y H:i:s', $expiretime) .' GMT'; $headers[] = 'Pragma: no-cache'; $headers[] = 'Content-Disposition: attachment; filename=config.seb'; $headers[] = 'Content-Type: application/seb'; return $headers; } /** * Get seb config content for a particular quiz. This method checks caps. * * @param string $cmid The course module ID for a quiz with config. * @return string SEB config string. */ public static function get_seb_config_content(string $cmid): string { // Try and get the course module. $cm = get_coursemodule_from_id('quiz', $cmid, 0, false, MUST_EXIST); // Make sure the user is logged in and has access to the module. require_login($cm->course, false, $cm); // Retrieve the config for quiz. $config = seb_quiz_settings::get_config_by_quiz_id($cm->instance); if (empty($config)) { throw new \moodle_exception('noconfigfound', 'quizaccess_seb', '', $cm->id); } return $config; } /** * Serve a file to browser for download. * * @param string $contents Contents of file. */ public static function send_seb_config_file(string $contents) { // We can now send the file back to the browser. foreach (self::get_seb_file_headers() as $header) { header($header); } echo($contents); } } PK9B\FTjcc)accessrule/seb/classes/link_generator.phpnu[. /** * Generate the links to open/download the Safe Exam Browser with correct settings. * * @package quizaccess_seb * @author Andrew Madden * @copyright 2019 Catalyst IT * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace quizaccess_seb; use moodle_url; defined('MOODLE_INTERNAL') || die(); /** * Generate the links to open/download the Safe Exam Browser with correct settings. * * @copyright 2020 Catalyst IT * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class link_generator { /** * Get a link to force the download of the file over https or sebs protocols. * * @param string $cmid Course module ID. * @param bool $seb Whether to use a seb:// scheme or fall back to http:// scheme. * @param bool $secure Whether to use HTTPS or HTTP protocol. * @return string A URL. */ public static function get_link(string $cmid, bool $seb = false, bool $secure = true): string { // Check if course module exists. get_coursemodule_from_id('quiz', $cmid, 0, false, MUST_EXIST); $url = new moodle_url('/mod/quiz/accessrule/seb/config.php?cmid=' . $cmid); if ($seb) { $secure ? $url->set_scheme('sebs') : $url->set_scheme('seb'); } else { $secure ? $url->set_scheme('https') : $url->set_scheme('http'); } return $url->out(); } } PK9B\8VN N %accessrule/seb/classes/config_key.phpnu[. /** * Class for generating and representing a Safe Exam Browser config key. * * @package quizaccess_seb * @author Andrew Madden * @copyright 2019 Catalyst IT * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace quizaccess_seb; defined('MOODLE_INTERNAL') || die(); /** * Class for generating and representing a Safe Exam Browser config key. * * @copyright 2020 Catalyst IT * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class config_key { /** @var string $hash The Config Key hash. */ private $hash; /** * The config_key constructor. * * @param string $hash The Config Key hash. */ public function __construct(string $hash) { $this->hash = $hash; } /** * Generate the Config Key hash from an SEB Config XML string. * * See https://safeexambrowser.org/developer/seb-config-key.html for more information about the process. * * @param string $xml A PList XML string, representing SEB config. * @return config_key This config key instance. */ public static function generate(string $xml): config_key { if (!empty($xml) && !helper::is_valid_seb_config($xml)) { throw new \invalid_parameter_exception('Invalid a PList XML string, representing SEB config'); } $plist = new property_list($xml); // Remove the key "originatorVersion" first. This key is exempted from the SEB-JSON hash (it's a special key // which doesn't have any functionality, it's just meta data indicating which SEB version saved the config file). $plist->delete_element('originatorVersion'); // Convert the plist XML of a decrypted/unencrypted SEB config file to a ordered JSON-like "SEB-JSON" object. $hash = $plist->to_json(); // Hash the JSON with SHA256. Defaults to required Base16 encoding. $hash = hash('SHA256', $hash); return new self($hash); } /** * Get the Config Key hash. * * @return string The Config Key hash */ public function get_hash(): string { return $this->hash; } } PK9B\2D``,accessrule/seb/classes/settings_provider.phpnu[. /** * Class for providing quiz settings, to make setting up quiz form manageable. * * To make sure there are no inconsistencies between data sets, run tests in tests/phpunit/settings_provider_test.php. * * @package quizaccess_seb * @author Luca Bösch * @author Andrew Madden * @author Dmitrii Metelkin * @copyright 2019 Catalyst IT * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace quizaccess_seb; use context_module; use context_user; use lang_string; use stdClass; use stored_file; defined('MOODLE_INTERNAL') || die(); /** * Helper class for providing quiz settings, to make setting up quiz form manageable. * * @copyright 2020 Catalyst IT * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class settings_provider { /** * No SEB should be used. */ const USE_SEB_NO = 0; /** * Use SEB and configure it manually. */ const USE_SEB_CONFIG_MANUALLY = 1; /** * Use SEB config from pre configured template. */ const USE_SEB_TEMPLATE = 2; /** * Use SEB config from uploaded config file. */ const USE_SEB_UPLOAD_CONFIG = 3; /** * Use client config. Not SEB config is required. */ const USE_SEB_CLIENT_CONFIG = 4; /** * Insert form element. * * @param \mod_quiz_mod_form $quizform the quiz settings form that is being built. * @param \MoodleQuickForm $mform the wrapped MoodleQuickForm. * @param \HTML_QuickForm_element $element Element to insert. * @param string $before Insert element before. */ protected static function insert_element(\mod_quiz_mod_form $quizform, \MoodleQuickForm $mform, \HTML_QuickForm_element $element, $before = 'security') { $mform->insertElementBefore($element, $before); } /** * Remove element from the form. * * @param \mod_quiz_mod_form $quizform the quiz settings form that is being built. * @param \MoodleQuickForm $mform the wrapped MoodleQuickForm. * @param string $elementname Element name. */ protected static function remove_element(\mod_quiz_mod_form $quizform, \MoodleQuickForm $mform, string $elementname) { if ($mform->elementExists($elementname)) { $mform->removeElement($elementname); $mform->setDefault($elementname, null); } } /** * Add help button to the element. * * @param \mod_quiz_mod_form $quizform the quiz settings form that is being built. * @param \MoodleQuickForm $mform the wrapped MoodleQuickForm. * @param string $elementname Element name. */ protected static function add_help_button(\mod_quiz_mod_form $quizform, \MoodleQuickForm $mform, string $elementname) { if ($mform->elementExists($elementname)) { $mform->addHelpButton($elementname, $elementname, 'quizaccess_seb'); } } /** * Set default value for the element. * * @param \mod_quiz_mod_form $quizform the quiz settings form that is being built. * @param \MoodleQuickForm $mform the wrapped MoodleQuickForm. * @param string $elementname Element name. * @param mixed $value Default value. */ protected static function set_default(\mod_quiz_mod_form $quizform, \MoodleQuickForm $mform, string $elementname, $value) { $mform->setDefault($elementname, $value); } /** * Set element type. * * @param \mod_quiz_mod_form $quizform the quiz settings form that is being built. * @param \MoodleQuickForm $mform the wrapped MoodleQuickForm. * @param string $elementname Element name. * @param string $type Type of the form element. */ protected static function set_type(\mod_quiz_mod_form $quizform, \MoodleQuickForm $mform, string $elementname, string $type) { $mform->setType($elementname, $type); } /** * Freeze form element. * * @param \mod_quiz_mod_form $quizform the quiz settings form that is being built. * @param \MoodleQuickForm $mform the wrapped MoodleQuickForm. * @param string $elementname Element name. */ protected static function freeze_element(\mod_quiz_mod_form $quizform, \MoodleQuickForm $mform, string $elementname) { if ($mform->elementExists($elementname)) { $mform->freeze($elementname); } } /** * Add SEB header element to the form. * * @param \mod_quiz_mod_form $quizform the quiz settings form that is being built. * @param \MoodleQuickForm $mform the wrapped MoodleQuickForm. */ protected static function add_seb_header_element(\mod_quiz_mod_form $quizform, \MoodleQuickForm $mform) { global $OUTPUT; $element = $mform->createElement('header', 'seb', get_string('seb', 'quizaccess_seb')); self::insert_element($quizform, $mform, $element); // Display notification about locked settings. if (self::is_seb_settings_locked($quizform->get_instance())) { $notify = new \core\output\notification( get_string('settingsfrozen', 'quizaccess_seb'), \core\output\notification::NOTIFY_WARNING ); $notifyelement = $mform->createElement('html', $OUTPUT->render($notify)); self::insert_element($quizform, $mform, $notifyelement); } if (self::is_conflicting_permissions($quizform->get_context())) { $notify = new \core\output\notification( get_string('conflictingsettings', 'quizaccess_seb'), \core\output\notification::NOTIFY_WARNING ); $notifyelement = $mform->createElement('html', $OUTPUT->render($notify)); self::insert_element($quizform, $mform, $notifyelement); } } /** * Add SEB usage element with all available options. * * @param \mod_quiz_mod_form $quizform the quiz settings form that is being built. * @param \MoodleQuickForm $mform the wrapped MoodleQuickForm. */ protected static function add_seb_usage_options(\mod_quiz_mod_form $quizform, \MoodleQuickForm $mform) { $element = $mform->createElement( 'select', 'seb_requiresafeexambrowser', get_string('seb_requiresafeexambrowser', 'quizaccess_seb'), self::get_requiresafeexambrowser_options($quizform->get_context()) ); self::insert_element($quizform, $mform, $element); self::set_type($quizform, $mform, 'seb_requiresafeexambrowser', PARAM_INT); self::set_default($quizform, $mform, 'seb_requiresafeexambrowser', self::USE_SEB_NO); self::add_help_button($quizform, $mform, 'seb_requiresafeexambrowser'); if (self::is_conflicting_permissions($quizform->get_context())) { self::freeze_element($quizform, $mform, 'seb_requiresafeexambrowser'); } } /** * Add Templates element. * * @param \mod_quiz_mod_form $quizform the quiz settings form that is being built. * @param \MoodleQuickForm $mform the wrapped MoodleQuickForm. */ protected static function add_seb_templates(\mod_quiz_mod_form $quizform, \MoodleQuickForm $mform) { $context = $quizform->get_context(); if (self::can_use_seb_template($context) || self::is_conflicting_permissions($context)) { $element = $mform->createElement( 'select', 'seb_templateid', get_string('seb_templateid', 'quizaccess_seb'), self::get_template_options($context->instanceid) ); } else { $element = $mform->createElement('hidden', 'seb_templateid'); } self::insert_element($quizform, $mform, $element); self::set_type($quizform, $mform, 'seb_templateid', PARAM_INT); self::set_default($quizform, $mform, 'seb_templateid', 0); self::add_help_button($quizform, $mform, 'seb_templateid'); // In case if the user can't use templates, but the quiz is configured to use them, // we'd like to display template, but freeze it. if (self::is_conflicting_permissions($context)) { self::freeze_element($quizform, $mform, 'seb_templateid'); } } /** * Add upload config file element. * * @param \mod_quiz_mod_form $quizform the quiz settings form that is being built. * @param \MoodleQuickForm $mform the wrapped MoodleQuickForm. */ protected static function add_seb_config_file(\mod_quiz_mod_form $quizform, \MoodleQuickForm $mform) { $itemid = 0; $draftitemid = 0; file_prepare_draft_area( $draftitemid, $quizform->get_context()->id, 'quizaccess_seb', 'filemanager_sebconfigfile', $itemid ); if (self::can_upload_seb_file($quizform->get_context())) { $element = $mform->createElement( 'filemanager', 'filemanager_sebconfigfile', get_string('filemanager_sebconfigfile', 'quizaccess_seb'), null, self::get_filemanager_options() ); } else { $element = $mform->createElement('hidden', 'filemanager_sebconfigfile'); } self::insert_element($quizform, $mform, $element); self::set_type($quizform, $mform, 'filemanager_sebconfigfile', PARAM_RAW); self::set_default($quizform, $mform, 'filemanager_sebconfigfile', $draftitemid); self::add_help_button($quizform, $mform, 'filemanager_sebconfigfile'); } /** * Add Show Safe Exam Browser download button. * * @param \mod_quiz_mod_form $quizform the quiz settings form that is being built. * @param \MoodleQuickForm $mform the wrapped MoodleQuickForm. */ protected static function add_seb_show_download_link(\mod_quiz_mod_form $quizform, \MoodleQuickForm $mform) { if (self::can_change_seb_showsebdownloadlink($quizform->get_context())) { $element = $mform->createElement('selectyesno', 'seb_showsebdownloadlink', get_string('seb_showsebdownloadlink', 'quizaccess_seb') ); self::insert_element($quizform, $mform, $element); self::set_type($quizform, $mform, 'seb_showsebdownloadlink', PARAM_BOOL); self::set_default($quizform, $mform, 'seb_showsebdownloadlink', 1); self::add_help_button($quizform, $mform, 'seb_showsebdownloadlink'); } } /** * Add Allowed Browser Exam Keys setting. * * @param \mod_quiz_mod_form $quizform the quiz settings form that is being built. * @param \MoodleQuickForm $mform the wrapped MoodleQuickForm. */ protected static function add_seb_allowedbrowserexamkeys(\mod_quiz_mod_form $quizform, \MoodleQuickForm $mform) { if (self::can_change_seb_allowedbrowserexamkeys($quizform->get_context())) { $element = $mform->createElement('textarea', 'seb_allowedbrowserexamkeys', get_string('seb_allowedbrowserexamkeys', 'quizaccess_seb') ); self::insert_element($quizform, $mform, $element); self::set_type($quizform, $mform, 'seb_allowedbrowserexamkeys', PARAM_RAW); self::set_default($quizform, $mform, 'seb_allowedbrowserexamkeys', ''); self::add_help_button($quizform, $mform, 'seb_allowedbrowserexamkeys'); } } /** * Add SEB config elements. * * @param \mod_quiz_mod_form $quizform the quiz settings form that is being built. * @param \MoodleQuickForm $mform the wrapped MoodleQuickForm. */ protected static function add_seb_config_elements(\mod_quiz_mod_form $quizform, \MoodleQuickForm $mform) { $defaults = self::get_seb_config_element_defaults(); $types = self::get_seb_config_element_types(); foreach (self::get_seb_config_elements() as $name => $type) { if (!self::can_manage_seb_config_setting($name, $quizform->get_context())) { $type = 'hidden'; } $element = $mform->createElement($type, $name, get_string($name, 'quizaccess_seb')); self::insert_element($quizform, $mform, $element); unset($element); // We need to make sure each &element only references the current element in loop. self::add_help_button($quizform, $mform, $name); if (isset($defaults[$name])) { self::set_default($quizform, $mform, $name, $defaults[$name]); } if (isset($types[$name])) { self::set_type($quizform, $mform, $name, $types[$name]); } } } /** * Add setting fields. * * @param \mod_quiz_mod_form $quizform the quiz settings form that is being built. * @param \MoodleQuickForm $mform the wrapped MoodleQuickForm. */ public static function add_seb_settings_fields(\mod_quiz_mod_form $quizform, \MoodleQuickForm $mform) { if (self::can_configure_seb($quizform->get_context())) { self::add_seb_header_element($quizform, $mform); self::add_seb_usage_options($quizform, $mform); self::add_seb_templates($quizform, $mform); self::add_seb_config_file($quizform, $mform); self::add_seb_show_download_link($quizform, $mform); self::add_seb_config_elements($quizform, $mform); self::add_seb_allowedbrowserexamkeys($quizform, $mform); self::hide_seb_elements($quizform, $mform); self::lock_seb_elements($quizform, $mform); } } /** * Hide SEB elements if required. * * @param \mod_quiz_mod_form $quizform the quiz settings form that is being built. * @param \MoodleQuickForm $mform the wrapped MoodleQuickForm. */ protected static function hide_seb_elements(\mod_quiz_mod_form $quizform, \MoodleQuickForm $mform) { foreach (self::get_quiz_hideifs() as $elname => $rules) { if ($mform->elementExists($elname)) { foreach ($rules as $hideif) { $mform->hideIf( $hideif->get_element(), $hideif->get_dependantname(), $hideif->get_condition(), $hideif->get_dependantvalue() ); } } } } /** * Lock SEB elements if required. * * @param \mod_quiz_mod_form $quizform the quiz settings form that is being built. * @param \MoodleQuickForm $mform the wrapped MoodleQuickForm. */ protected static function lock_seb_elements(\mod_quiz_mod_form $quizform, \MoodleQuickForm $mform) { if (self::is_seb_settings_locked($quizform->get_instance()) || self::is_conflicting_permissions($quizform->get_context())) { // Freeze common quiz settings. self::freeze_element($quizform, $mform, 'seb_requiresafeexambrowser'); self::freeze_element($quizform, $mform, 'seb_templateid'); self::freeze_element($quizform, $mform, 'seb_showsebdownloadlink'); self::freeze_element($quizform, $mform, 'seb_allowedbrowserexamkeys'); $quizsettings = seb_quiz_settings::get_by_quiz_id((int) $quizform->get_instance()); // If the file has been uploaded, then replace it with the link to download the file. if (!empty($quizsettings) && $quizsettings->get('requiresafeexambrowser') == self::USE_SEB_UPLOAD_CONFIG) { self::remove_element($quizform, $mform, 'filemanager_sebconfigfile'); if ($link = self::get_uploaded_seb_file_download_link($quizform, $mform)) { $element = $mform->createElement( 'static', 'filemanager_sebconfigfile', get_string('filemanager_sebconfigfile', 'quizaccess_seb'), $link ); self::insert_element($quizform, $mform, $element, 'seb_showsebdownloadlink'); } } // Remove template ID if not using template for this quiz. if (empty($quizsettings) || $quizsettings->get('requiresafeexambrowser') != self::USE_SEB_TEMPLATE) { $mform->removeElement('seb_templateid'); } // Freeze all SEB specific settings. foreach (self::get_seb_config_elements() as $element => $type) { self::freeze_element($quizform, $mform, $element); } } } /** * Return uploaded SEB config file link. * * @param \mod_quiz_mod_form $quizform the quiz settings form that is being built. * @param \MoodleQuickForm $mform the wrapped MoodleQuickForm. * @return string */ protected static function get_uploaded_seb_file_download_link(\mod_quiz_mod_form $quizform, \MoodleQuickForm $mform): string { $link = ''; $file = self::get_module_context_sebconfig_file($quizform->get_coursemodule()->id); if ($file) { $url = \moodle_url::make_pluginfile_url( $file->get_contextid(), $file->get_component(), $file->get_filearea(), $file->get_itemid(), $file->get_filepath(), $file->get_filename(), true ); $link = \html_writer::link($url, get_string('downloadsebconfig', 'quizaccess_seb')); } return $link; } /** * Get the type of element for each of the form elements in quiz settings. * * Contains all setting elements. Array key is name of 'form element'/'database column (excluding prefix)'. * * @return array All quiz form elements to be added and their types. */ public static function get_seb_config_elements(): array { return [ 'seb_linkquitseb' => 'text', 'seb_userconfirmquit' => 'selectyesno', 'seb_allowuserquitseb' => 'selectyesno', 'seb_quitpassword' => 'passwordunmask', 'seb_allowreloadinexam' => 'selectyesno', 'seb_showsebtaskbar' => 'selectyesno', 'seb_showreloadbutton' => 'selectyesno', 'seb_showtime' => 'selectyesno', 'seb_showkeyboardlayout' => 'selectyesno', 'seb_showwificontrol' => 'selectyesno', 'seb_enableaudiocontrol' => 'selectyesno', 'seb_muteonstartup' => 'selectyesno', 'seb_allowcapturecamera' => 'selectyesno', 'seb_allowcapturemicrophone' => 'selectyesno', 'seb_allowspellchecking' => 'selectyesno', 'seb_activateurlfiltering' => 'selectyesno', 'seb_filterembeddedcontent' => 'selectyesno', 'seb_expressionsallowed' => 'textarea', 'seb_regexallowed' => 'textarea', 'seb_expressionsblocked' => 'textarea', 'seb_regexblocked' => 'textarea', ]; } /** * Get the types of the quiz settings elements. * @return array List of types for the setting elements. */ public static function get_seb_config_element_types(): array { return [ 'seb_linkquitseb' => PARAM_RAW, 'seb_userconfirmquit' => PARAM_BOOL, 'seb_allowuserquitseb' => PARAM_BOOL, 'seb_quitpassword' => PARAM_RAW, 'seb_allowreloadinexam' => PARAM_BOOL, 'seb_showsebtaskbar' => PARAM_BOOL, 'seb_showreloadbutton' => PARAM_BOOL, 'seb_showtime' => PARAM_BOOL, 'seb_showkeyboardlayout' => PARAM_BOOL, 'seb_showwificontrol' => PARAM_BOOL, 'seb_enableaudiocontrol' => PARAM_BOOL, 'seb_muteonstartup' => PARAM_BOOL, 'seb_allowcapturecamera' => PARAM_BOOL, 'seb_allowcapturemicrophone' => PARAM_BOOL, 'seb_allowspellchecking' => PARAM_BOOL, 'seb_activateurlfiltering' => PARAM_BOOL, 'seb_filterembeddedcontent' => PARAM_BOOL, 'seb_expressionsallowed' => PARAM_RAW, 'seb_regexallowed' => PARAM_RAW, 'seb_expressionsblocked' => PARAM_RAW, 'seb_regexblocked' => PARAM_RAW, ]; } /** * Check that we have conflicting permissions with the current SEB settings. * * Check if the existing settings of the quiz (if any) are conflicting with the * capabilities of the managing user. * * E.g. a quiz is using an SEB template and a site admin is able to select this * option while a course manager cannot. Therefore it will return true for a course * manager and return false for a site admin. * * @param \context $context Context used with capability checking. * * @return bool */ public static function is_conflicting_permissions(\context $context) { if ($context instanceof \context_course) { return false; } $settings = seb_quiz_settings::get_record(['cmid' => (int) $context->instanceid]); if (empty($settings)) { return false; } if (!self::can_use_seb_template($context) && $settings->get('requiresafeexambrowser') == self::USE_SEB_TEMPLATE) { return true; } if (!self::can_use_seb_client_config($context) && $settings->get('requiresafeexambrowser') == self::USE_SEB_CLIENT_CONFIG) { return true; } if (!self::can_upload_seb_file($context) && $settings->get('requiresafeexambrowser') == self::USE_SEB_UPLOAD_CONFIG) { return true; } if (!self::can_configure_manually($context) && $settings->get('requiresafeexambrowser') == self::USE_SEB_CONFIG_MANUALLY) { return true; } return false; } /** * Returns a list of all options of SEB usage. * * @param \context $context Context used with capability checking selection options. * @return array */ public static function get_requiresafeexambrowser_options(\context $context): array { $options[self::USE_SEB_NO] = get_string('no'); if (self::can_configure_manually($context) || self::is_conflicting_permissions($context)) { $options[self::USE_SEB_CONFIG_MANUALLY] = get_string('seb_use_manually', 'quizaccess_seb'); } if (self::can_use_seb_template($context) || self::is_conflicting_permissions($context)) { if (!empty(self::get_template_options($context->instanceid))) { $options[self::USE_SEB_TEMPLATE] = get_string('seb_use_template', 'quizaccess_seb'); } } if (self::can_upload_seb_file($context) || self::is_conflicting_permissions($context)) { $options[self::USE_SEB_UPLOAD_CONFIG] = get_string('seb_use_upload', 'quizaccess_seb'); } if (self::can_use_seb_client_config($context) || self::is_conflicting_permissions($context)) { $options[self::USE_SEB_CLIENT_CONFIG] = get_string('seb_use_client', 'quizaccess_seb'); } return $options; } /** * Returns a list of templates. * @return array */ protected static function get_template_options($cmid): array { $templates = []; $templatetable = template::TABLE; $sebquizsettingstable = seb_quiz_settings::TABLE; $select = "enabled = 1 OR EXISTS ( SELECT 1 FROM {{$sebquizsettingstable}} WHERE templateid = {{$templatetable}}.id AND cmid = ? )"; if ($records = template::get_records_select($select, [$cmid], 'id, name')) { foreach ($records as $record) { $templates[$record->get('id')] = $record->get('name'); } } return $templates; } /** * Returns a list of options for the file manager element. * @return array */ public static function get_filemanager_options(): array { return [ 'subdirs' => 0, 'maxfiles' => 1, 'accepted_types' => ['.seb'] ]; } /** * Get the default values of the quiz settings. * * Array key is name of 'form element'/'database column (excluding prefix)'. * * @return array List of settings and their defaults. */ public static function get_seb_config_element_defaults(): array { return [ 'seb_linkquitseb' => '', 'seb_userconfirmquit' => 1, 'seb_allowuserquitseb' => 1, 'seb_quitpassword' => '', 'seb_allowreloadinexam' => 1, 'seb_showsebtaskbar' => 1, 'seb_showreloadbutton' => 1, 'seb_showtime' => 1, 'seb_showkeyboardlayout' => 1, 'seb_showwificontrol' => 0, 'seb_enableaudiocontrol' => 0, 'seb_allowcapturecamera' => 0, 'seb_allowcapturemicrophone' => 0, 'seb_muteonstartup' => 0, 'seb_allowspellchecking' => 0, 'seb_activateurlfiltering' => 0, 'seb_filterembeddedcontent' => 0, 'seb_expressionsallowed' => '', 'seb_regexallowed' => '', 'seb_expressionsblocked' => '', 'seb_regexblocked' => '', ]; } /** * Validate that if a file has been uploaded by current user, that it is a valid PLIST XML file. * This function is only called if requiresafeexambrowser == settings_provider::USE_SEB_UPLOAD_CONFIG. * * @param string $itemid Item ID of file in user draft file area. * @return void|lang_string */ public static function validate_draftarea_configfile($itemid) { // When saving the settings, this value will be null. if (is_null($itemid)) { return; } // If there is a config file uploaded, make sure it is a PList XML file. $file = self::get_current_user_draft_file($itemid); // If we require an SEB config uploaded, and the file exists, parse it. if ($file) { if (!helper::is_valid_seb_config($file->get_content())) { return new lang_string('fileparsefailed', 'quizaccess_seb'); } } // If we require an SEB config uploaded, and the file does not exist, error. if (!$file) { return new lang_string('filenotpresent', 'quizaccess_seb'); } } /** * Try and get a file in the user draft filearea by itemid. * * @param string $itemid Item ID of the file. * @return stored_file|null Returns null if no file is found. */ public static function get_current_user_draft_file(string $itemid): ?stored_file { global $USER; $context = context_user::instance($USER->id); $fs = get_file_storage(); if (!$files = $fs->get_area_files($context->id, 'user', 'draft', $itemid, 'id DESC', false)) { return null; } return reset($files); } /** * Get the file that is stored in the course module file area. * * @param string $cmid The course module id which is used as an itemid reference. * @return stored_file|null Returns null if no file is found. */ public static function get_module_context_sebconfig_file(string $cmid): ?stored_file { $fs = new \file_storage(); $context = context_module::instance($cmid); if (!$files = $fs->get_area_files($context->id, 'quizaccess_seb', 'filemanager_sebconfigfile', 0, 'id DESC', false)) { return null; } return reset($files); } /** * Saves filemanager_sebconfigfile files to the moodle storage backend. * * @param string $draftitemid The id of the draft area to use. * @param string $cmid The cmid of for the quiz. * @return bool Always true */ public static function save_filemanager_sebconfigfile_draftarea(string $draftitemid, string $cmid): bool { if ($draftitemid) { $context = context_module::instance($cmid); file_save_draft_area_files($draftitemid, $context->id, 'quizaccess_seb', 'filemanager_sebconfigfile', 0, []); } return true; } /** * Cleanup function to delete the saved config when it has not been specified. * This will be called when settings_provider::USE_SEB_UPLOAD_CONFIG is not true. * * @param string $cmid The cmid of for the quiz. * @return bool Always true or exception if error occurred */ public static function delete_uploaded_config_file(string $cmid): bool { $file = self::get_module_context_sebconfig_file($cmid); if (!empty($file)) { return $file->delete(); } return false; } /** * Check if the current user can configure SEB. * * @param \context $context Context to check access in. * @return bool */ public static function can_configure_seb(\context $context): bool { return has_capability('quizaccess/seb:manage_seb_requiresafeexambrowser', $context); } /** * Check if the current user can select to use the SEB client configuration. * * @param \context $context Context to check access in. * @return bool */ public static function can_use_seb_client_config(\context $context): bool { return has_capability('quizaccess/seb:manage_seb_usesebclientconfig', $context); } /** * Check if the current user can use preconfigured templates. * * @param \context $context Context to check access in. * @return bool */ public static function can_use_seb_template(\context $context): bool { return has_capability('quizaccess/seb:manage_seb_templateid', $context); } /** * Check if the current user can upload own SEB config file. * * @param \context $context Context to check access in. * @return bool */ public static function can_upload_seb_file(\context $context): bool { return has_capability('quizaccess/seb:manage_filemanager_sebconfigfile', $context); } /** * Check if the current user can change Show Safe Exam Browser download button setting. * * @param \context $context Context to check access in. * @return bool */ public static function can_change_seb_showsebdownloadlink(\context $context): bool { return has_capability('quizaccess/seb:manage_seb_showsebdownloadlink', $context); } /** * Check if the current user can change Allowed Browser Exam Keys setting. * * @param \context $context Context to check access in. * @return bool */ public static function can_change_seb_allowedbrowserexamkeys(\context $context): bool { return has_capability('quizaccess/seb:manage_seb_allowedbrowserexamkeys', $context); } /** * Check if the current user can config SEB manually. * * @param \context $context Context to check access in. * @return bool */ public static function can_configure_manually(\context $context): bool { if (!has_capability('quizaccess/seb:manage_seb_configuremanually', $context)) { return false; } foreach (self::get_seb_config_elements() as $name => $type) { if (self::can_manage_seb_config_setting($name, $context)) { return true; } } return false; } /** * Check if the current user can manage provided SEB setting. * * @param string $settingname Name of the setting. * @param \context $context Context to check access in. * @return bool */ public static function can_manage_seb_config_setting(string $settingname, \context $context): bool { $capsttocheck = []; foreach (self::get_seb_settings_map() as $type => $settings) { $capsttocheck = self::build_config_capabilities_to_check($settingname, $settings); if (!empty($capsttocheck)) { break; } } foreach ($capsttocheck as $capability) { // Capability must exist. if (!$capinfo = get_capability_info($capability)) { throw new \coding_exception("Capability '{$capability}' was not found! This has to be fixed in code."); } } return has_all_capabilities($capsttocheck, $context); } /** * Helper method to build a list of capabilities to check. * * @param string $settingname Given setting name to build caps for. * @param array $settings A list of settings to go through. * @return array */ protected static function build_config_capabilities_to_check(string $settingname, array $settings): array { $capsttocheck = []; foreach ($settings as $setting => $children) { if ($setting == $settingname) { $capsttocheck[$setting] = self::build_setting_capability_name($setting); break; // Found what we need exit the loop. } // Recursively check all children. $capsttocheck = self::build_config_capabilities_to_check($settingname, $children); if (!empty($capsttocheck)) { // Matching child found, add the parent capability to the list of caps to check. $capsttocheck[$setting] = self::build_setting_capability_name($setting); break; // Found what we need exit the loop. } } return $capsttocheck; } /** * Helper method to return a map of all settings. * * @return array */ public static function get_seb_settings_map(): array { return [ self::USE_SEB_NO => [ ], self::USE_SEB_CONFIG_MANUALLY => [ 'seb_showsebdownloadlink' => [], 'seb_linkquitseb' => [], 'seb_userconfirmquit' => [], 'seb_allowuserquitseb' => [ 'seb_quitpassword' => [] ], 'seb_allowreloadinexam' => [], 'seb_showsebtaskbar' => [ 'seb_showreloadbutton' => [], 'seb_showtime' => [], 'seb_showkeyboardlayout' => [], 'seb_showwificontrol' => [], ], 'seb_enableaudiocontrol' => [ 'seb_muteonstartup' => [], ], 'seb_allowcapturecamera' => [], 'seb_allowcapturemicrophone' => [], 'seb_allowspellchecking' => [], 'seb_activateurlfiltering' => [ 'seb_filterembeddedcontent' => [], 'seb_expressionsallowed' => [], 'seb_regexallowed' => [], 'seb_expressionsblocked' => [], 'seb_regexblocked' => [], ], ], self::USE_SEB_TEMPLATE => [ 'seb_templateid' => [], 'seb_showsebdownloadlink' => [], 'seb_allowuserquitseb' => [ 'seb_quitpassword' => [], ], ], self::USE_SEB_UPLOAD_CONFIG => [ 'filemanager_sebconfigfile' => [], 'seb_showsebdownloadlink' => [], 'seb_allowedbrowserexamkeys' => [], ], self::USE_SEB_CLIENT_CONFIG => [ 'seb_showsebdownloadlink' => [], 'seb_allowedbrowserexamkeys' => [], ], ]; } /** * Get allowed settings for provided SEB usage type. * * @param int $requiresafeexambrowser SEB usage type. * @return array */ private static function get_allowed_settings(int $requiresafeexambrowser): array { $result = []; $map = self::get_seb_settings_map(); if (!key_exists($requiresafeexambrowser, $map)) { return $result; } return self::build_allowed_settings($map[$requiresafeexambrowser]); } /** * Recursive method to build a list of allowed settings. * * @param array $settings A list of settings from settings map. * @return array */ private static function build_allowed_settings(array $settings): array { $result = []; foreach ($settings as $name => $children) { $result[] = $name; foreach ($children as $childname => $child) { $result[] = $childname; $result = array_merge($result, self::build_allowed_settings($child)); } } return $result; } /** * Get the conditions that an element should be hid in the form. Expects matching using 'eq'. * * Array key is name of 'form element'/'database column (excluding prefix)'. * Values are instances of hideif_rule class. * * @return array List of rules per element. */ public static function get_quiz_hideifs(): array { $hideifs = []; // We are building rules based on the settings map, that means children will be dependant on parent. // In most cases it's all pretty standard. // However it could be some specific cases for some fields, which will be overridden later. foreach (self::get_seb_settings_map() as $type => $settings) { foreach ($settings as $setting => $children) { $hideifs[$setting][] = new hideif_rule($setting, 'seb_requiresafeexambrowser', 'noteq', $type); foreach ($children as $childname => $child) { $hideifs[$childname][] = new hideif_rule($childname, 'seb_requiresafeexambrowser', 'noteq', $type); $hideifs[$childname][] = new hideif_rule($childname, $setting, 'eq', 0); } } } // Specific case for "Enable quitting of SEB". It should available for Manual and Template. $hideifs['seb_allowuserquitseb'] = [ new hideif_rule('seb_allowuserquitseb', 'seb_requiresafeexambrowser', 'eq', self::USE_SEB_NO), new hideif_rule('seb_allowuserquitseb', 'seb_requiresafeexambrowser', 'eq', self::USE_SEB_CLIENT_CONFIG), new hideif_rule('seb_allowuserquitseb', 'seb_requiresafeexambrowser', 'eq', self::USE_SEB_UPLOAD_CONFIG), ]; // Specific case for "Quit password". It should be available for Manual and Template. As it's parent. $hideifs['seb_quitpassword'] = [ new hideif_rule('seb_quitpassword', 'seb_requiresafeexambrowser', 'eq', self::USE_SEB_NO), new hideif_rule('seb_quitpassword', 'seb_requiresafeexambrowser', 'eq', self::USE_SEB_CLIENT_CONFIG), new hideif_rule('seb_quitpassword', 'seb_requiresafeexambrowser', 'eq', self::USE_SEB_UPLOAD_CONFIG), new hideif_rule('seb_quitpassword', 'seb_allowuserquitseb', 'eq', 0), ]; // Specific case for "Show Safe Exam Browser download button". It should be available for all cases, except No Seb. $hideifs['seb_showsebdownloadlink'] = [ new hideif_rule('seb_showsebdownloadlink', 'seb_requiresafeexambrowser', 'eq', self::USE_SEB_NO) ]; // Specific case for "Allowed Browser Exam Keys". It should be available for Template and Browser config. $hideifs['seb_allowedbrowserexamkeys'] = [ new hideif_rule('seb_allowedbrowserexamkeys', 'seb_requiresafeexambrowser', 'eq', self::USE_SEB_NO), new hideif_rule('seb_allowedbrowserexamkeys', 'seb_requiresafeexambrowser', 'eq', self::USE_SEB_CONFIG_MANUALLY), new hideif_rule('seb_allowedbrowserexamkeys', 'seb_requiresafeexambrowser', 'eq', self::USE_SEB_TEMPLATE), ]; return $hideifs; } /** * Build a capability name for the provided SEB setting. * * @param string $settingname Name of the setting. * @return string */ public static function build_setting_capability_name(string $settingname): string { if (!key_exists($settingname, self::get_seb_config_elements())) { throw new \coding_exception('Incorrect SEB quiz setting ' . $settingname); } return 'quizaccess/seb:manage_' . $settingname; } /** * Check if settings is locked. * * @param int $quizid Quiz ID. * @return bool */ public static function is_seb_settings_locked($quizid): bool { if (empty($quizid)) { return false; } return quiz_has_attempts($quizid); } /** * Filter a standard class by prefix. * * @param stdClass $settings Quiz settings object. * @return stdClass Filtered object. */ private static function filter_by_prefix(\stdClass $settings): stdClass { $newsettings = new \stdClass(); foreach ($settings as $name => $setting) { // Only add it, if not there. if (strpos($name, "seb_") === 0) { $newsettings->$name = $setting; // Add new key. } } return $newsettings; } /** * Filter settings based on the setting map. Set value of not allowed settings to null. * * @param stdClass $settings Quiz settings. * @return \stdClass */ private static function filter_by_settings_map(stdClass $settings): stdClass { if (!isset($settings->seb_requiresafeexambrowser)) { return $settings; } $newsettings = new \stdClass(); $newsettings->seb_requiresafeexambrowser = $settings->seb_requiresafeexambrowser; $allowedsettings = self::get_allowed_settings((int)$newsettings->seb_requiresafeexambrowser); unset($settings->seb_requiresafeexambrowser); foreach ($settings as $name => $value) { if (!in_array($name, $allowedsettings)) { $newsettings->$name = null; } else { $newsettings->$name = $value; } } return $newsettings; } /** * Filter quiz settings for this plugin only. * * @param stdClass $settings Quiz settings. * @return stdClass Filtered settings. */ public static function filter_plugin_settings(stdClass $settings): stdClass { $settings = self::filter_by_prefix($settings); $settings = self::filter_by_settings_map($settings); return self::strip_all_prefixes($settings); } /** * Strip the seb_ prefix from each setting key. * * @param \stdClass $settings Object containing settings. * @return \stdClass The modified settings object. */ private static function strip_all_prefixes(\stdClass $settings): stdClass { $newsettings = new \stdClass(); foreach ($settings as $name => $setting) { $newname = preg_replace("/^seb_/", "", $name); $newsettings->$newname = $setting; // Add new key. } return $newsettings; } /** * Add prefix to string. * * @param string $name String to add prefix to. * @return string String with prefix. */ public static function add_prefix(string $name): string { if (strpos($name, 'seb_') !== 0) { $name = 'seb_' . $name; } return $name; } } PK9B\qU22-accessrule/seb/classes/seb_access_manager.phpnu[. /** * Manage the access to the quiz. * * @package quizaccess_seb * @author Tim Hunt * @author Luca Bösch * @author Andrew Madden * @author Dmitrii Metelkin * @copyright 2019 Catalyst IT * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace quizaccess_seb; use context_module; use mod_quiz\quiz_settings; defined('MOODLE_INTERNAL') || die(); /** * Manage the access to the quiz. * * @copyright 2020 Catalyst IT * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class seb_access_manager { /** Header sent by Safe Exam Browser containing the Config Key hash. */ private const CONFIG_KEY_HEADER = 'HTTP_X_SAFEEXAMBROWSER_CONFIGKEYHASH'; /** Header sent by Safe Exam Browser containing the Browser Exam Key hash. */ private const BROWSER_EXAM_KEY_HEADER = 'HTTP_X_SAFEEXAMBROWSER_REQUESTHASH'; /** @var quiz_settings $quiz A quiz object containing all information pertaining to current quiz. */ private $quiz; /** @var seb_quiz_settings $quizsettings A quiz settings persistent object containing plugin settings */ private $quizsettings; /** @var context_module $context Context of this quiz activity. */ private $context; /** @var string|null $validconfigkey Expected valid SEB config key. */ private $validconfigkey = null; /** * The access_manager constructor. * * @param quiz_settings $quiz The details of the quiz. */ public function __construct(quiz_settings $quiz) { $this->quiz = $quiz; $this->context = context_module::instance($quiz->get_cmid()); $this->quizsettings = seb_quiz_settings::get_by_quiz_id($quiz->get_quizid()); $this->validconfigkey = seb_quiz_settings::get_config_key_by_quiz_id($quiz->get_quizid()); } /** * Validate browser exam key. It will validate a provided browser exam key if provided, then will fall back to checking * the header. * * @param string|null $browserexamkey Optional. Can validate a provided key, or will fall back to checking header. * @param string|null $url Optionally provide URL of page to validate. * @return bool */ public function validate_browser_exam_key(?string $browserexamkey = null, ?string $url = null): bool { if (!$this->should_validate_browser_exam_key()) { // Browser exam key should not be checked, so do not prevent access. return true; } if (!$this->is_allowed_browser_examkeys_configured()) { return true; // If no browser exam keys, no check required. } if (empty($browserexamkey)) { $browserexamkey = $this->get_received_browser_exam_key(); } $validbrowserexamkeys = $this->quizsettings->get('allowedbrowserexamkeys'); // If the Browser Exam Key header isn't present, prevent access. if (is_null($browserexamkey)) { return false; } return $this->check_browser_exam_keys($validbrowserexamkeys, $browserexamkey, $url); } /** * Validate a config key. It will check a provided config key if provided then will fall back to checking config * key in header. * * @param string|null $configkey Optional. Can validate a provided key, or will fall back to checking header. * @param string|null $url URL of page to validate. * @return bool */ public function validate_config_key(?string $configkey = null, ?string $url = null): bool { if (!$this->should_validate_config_key()) { // Config key should not be checked, so do not prevent access. return true; } // If using client config, or with no requirement, then no check required. $requiredtype = $this->get_seb_use_type(); if ($requiredtype == settings_provider::USE_SEB_NO || $requiredtype == settings_provider::USE_SEB_CLIENT_CONFIG) { return true; } if (empty($configkey)) { $configkey = $this->get_received_config_key(); } if (empty($this->validconfigkey)) { return false; // No config key has been saved. } if (is_null($configkey)) { return false; } // Check if there is a valid config key supplied in the header. return $this->check_key($this->validconfigkey, $configkey, $url); } /** * Check if Safe Exam Browser is required to access quiz. * If quizsettings do not exist, then there is no requirement for using SEB. * * @return bool If required. */ public function seb_required(): bool { if (!$this->quizsettings) { return false; } else { return $this->get_seb_use_type() != settings_provider::USE_SEB_NO; } } /** * This is the basic check for the Safe Exam Browser previously used in the quizaccess_safebrowser plugin that * managed basic Moodle interactions with SEB. * * @return bool */ public function validate_basic_header(): bool { if (!$this->should_validate_basic_header()) { // Config key should not be checked, so do not prevent access. return true; } if ($this->get_seb_use_type() == settings_provider::USE_SEB_CLIENT_CONFIG) { return $this->is_using_seb(); } return true; } /** * Check if using Safe Exam Browser. * * @return bool */ public function is_using_seb(): bool { if (isset($_SERVER['HTTP_USER_AGENT'])) { return strpos($_SERVER['HTTP_USER_AGENT'], 'SEB') !== false; } return false; } /** * Check if user has any capability to bypass the Safe Exam Browser requirement. * * @return bool True if user can bypass check. */ public function can_bypass_seb(): bool { return has_capability('quizaccess/seb:bypassseb', $this->context); } /** * Return the full URL that was used to request the current page, which is * what we need for verifying the X-SafeExamBrowser-RequestHash header. */ private function get_this_page_url(): string { global $CFG, $FULLME; // If $FULLME not set fall back to wwwroot. if ($FULLME == null) { return $CFG->wwwroot; } return $FULLME; } /** * Return expected SEB config key. * * @return string|null */ public function get_valid_config_key(): ?string { return $this->validconfigkey; } /** * Getter for the quiz object. * * @return \mod_quiz\quiz_settings */ public function get_quiz(): quiz_settings { return $this->quiz; } /** * Check that at least one browser exam key exists in the quiz settings. * * @return bool True if one or more keys are set in quiz settings. */ private function is_allowed_browser_examkeys_configured(): bool { return !empty($this->quizsettings->get('allowedbrowserexamkeys')); } /** * Check the hash from the request header against the permitted browser exam keys. * * @param array $keys Allowed browser exam keys. * @param string $header The value of the X-SafeExamBrowser-RequestHash to check. * @param string|null $url URL of page to validate. * @return bool True if the hash matches. */ private function check_browser_exam_keys(array $keys, string $header, ?string $url = null): bool { foreach ($keys as $key) { if ($this->check_key($key, $header, $url)) { return true; } } return false; } /** * Check the hash from the request header against a single permitted key. * * @param string $validkey An allowed key. * @param string $key The value of X-SafeExamBrowser-RequestHash, X-SafeExamBrowser-ConfigKeyHash or a provided key to check. * @param string|null $url URL of page to validate. * @return bool True if the hash matches. */ private function check_key(string $validkey, string $key, ?string $url = null): bool { if (empty($url)) { $url = $this->get_this_page_url(); } return hash('sha256', $url . $validkey) === $key; } /** * Returns Safe Exam Browser Config Key hash. * * @return string|null */ public function get_received_config_key(): ?string { if (isset($_SERVER[self::CONFIG_KEY_HEADER])) { return trim($_SERVER[self::CONFIG_KEY_HEADER]); } return null; } /** * Returns the Browser Exam Key hash. * * @return string|null */ public function get_received_browser_exam_key(): ?string { if (isset($_SERVER[self::BROWSER_EXAM_KEY_HEADER])) { return trim($_SERVER[self::BROWSER_EXAM_KEY_HEADER]); } return null; } /** * Get type of SEB usage for the quiz. * * @return int */ public function get_seb_use_type(): int { if (empty($this->quizsettings)) { return settings_provider::USE_SEB_NO; } else { return $this->quizsettings->get('requiresafeexambrowser'); } } /** * Should validate basic header? * * @return bool */ public function should_validate_basic_header(): bool { return in_array($this->get_seb_use_type(), [ settings_provider::USE_SEB_CLIENT_CONFIG, ]); } /** * Should validate SEB config key? * @return bool */ public function should_validate_config_key(): bool { return in_array($this->get_seb_use_type(), [ settings_provider::USE_SEB_CONFIG_MANUALLY, settings_provider::USE_SEB_TEMPLATE, settings_provider::USE_SEB_UPLOAD_CONFIG, ]); } /** * Should validate browser exam key? * * @return bool */ public function should_validate_browser_exam_key(): bool { return in_array($this->get_seb_use_type(), [ settings_provider::USE_SEB_UPLOAD_CONFIG, settings_provider::USE_SEB_CLIENT_CONFIG, ]); } /** * Set session access for quiz. * * @param bool $accessallowed */ public function set_session_access(bool $accessallowed): void { global $SESSION; if (!isset($SESSION->quizaccess_seb_access)) { $SESSION->quizaccess_seb_access = []; } $SESSION->quizaccess_seb_access[$this->quiz->get_cmid()] = $accessallowed; } /** * Check session access for quiz if already set. * * @return bool */ public function validate_session_access(): bool { global $SESSION; return !empty($SESSION->quizaccess_seb_access[$this->quiz->get_cmid()]); } /** * Unset the global session access variable for this quiz. */ public function clear_session_access(): void { global $SESSION; unset($SESSION->quizaccess_seb_access[$this->quiz->get_cmid()]); } /** * Redirect to SEB config link. This will force Safe Exam Browser to be reconfigured. */ public function redirect_to_seb_config_link(): void { global $PAGE; $seblink = \quizaccess_seb\link_generator::get_link($this->quiz->get_cmid(), true, is_https()); $PAGE->requires->js_amd_inline("document.location.replace('" . $seblink . "')"); } /** * Check if we need to redirect to SEB config link. * * @return bool */ public function should_redirect_to_seb_config_link(): bool { // We check if there is an existing config key header. If there is none, we assume that // the SEB application is not using header verification so auto redirect should not proceed. $haskeyinheader = !is_null($this->get_received_config_key()); return $this->is_using_seb() && get_config('quizaccess_seb', 'autoreconfigureseb') && $haskeyinheader; } } PK9B\1accessrule/seb/classes/event/template_created.phpnu[. /** * Event for when a template is created. * * @package quizaccess_seb * @author Nicholas Hoobin * @copyright 2020 Catalyst IT * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace quizaccess_seb\event; use context_system; use core\event\base; use quizaccess_seb\template; defined('MOODLE_INTERNAL') || die(); /** * Event for when a template is created. * * @copyright 2020 Catalyst IT * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class template_created extends base { /** * Create event with strict parameters. * * Define strict parameters to create event with instead of relying on internal validation of array. Better code practice. * Easier for consumers of this class to know what data must be supplied and observers can have more trust in event data. * * @param template $template SEB template. * @param context_system $context Context system. * @return base */ public static function create_strict(template $template, context_system $context): base { global $USER; $tid = $template->get('id'); return self::create([ 'userid' => $USER->id, 'objectid' => $tid, 'context' => $context, ]); } /** * Initialize the event data. */ protected function init() { $this->data['objecttable'] = 'quizaccess_seb_template'; $this->data['crud'] = 'c'; $this->data['edulevel'] = self::LEVEL_PARTICIPATING; } /** * Get the name of the event. * * @return string Name of event. */ public static function get_name() { return get_string('event:templatecreated', 'quizaccess_seb'); } /** * Returns relevant URL. * @return \moodle_url */ public function get_url() { $params = [ 'id' => $this->objectid, 'action' => 'edit', ]; return new \moodle_url('/mod/quiz/accessrule/seb/template.php', $params); } /** * Returns non-localised event description with id's for admin use only. * * @return string Description. */ public function get_description() { return "The user with id '$this->userid' has created a template with id '$this->objectid'."; } /** * This is used when restoring course logs where it is required that we * map the objectid to it's new value in the new course. * * @return array Mapping of object id. */ public static function get_objectid_mapping(): array { return ['db' => 'quizaccess_seb_template', 'restore' => 'quizaccess_seb_template']; } /** * This is used when restoring course logs where it is required that we * map the information in 'other' to it's new value in the new course. * * @return array List of mapping of other ids. */ public static function get_other_mapping(): array { return []; } } PK9B\/DJ2accessrule/seb/classes/event/template_disabled.phpnu[. /** * Event for when a template is disabled. * * @package quizaccess_seb * @author Nicholas Hoobin * @copyright 2020 Catalyst IT * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace quizaccess_seb\event; use context_system; use core\event\base; use quizaccess_seb\template; defined('MOODLE_INTERNAL') || die(); /** * Event for when a template is disabled. * * @copyright 2020 Catalyst IT * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class template_disabled extends base { /** * Create event with strict parameters. * * Define strict parameters to create event with instead of relying on internal validation of array. Better code practice. * Easier for consumers of this class to know what data must be supplied and observers can have more trust in event data. * * @param template $template SEB template. * @param context_system $context Context system. * @return base */ public static function create_strict(template $template, context_system $context): base { global $USER; $tid = $template->get('id'); return self::create([ 'userid' => $USER->id, 'objectid' => $tid, 'context' => $context, ]); } /** * Initialize the event data. */ protected function init() { $this->data['objecttable'] = 'quizaccess_seb_template'; $this->data['crud'] = 'u'; $this->data['edulevel'] = self::LEVEL_PARTICIPATING; } /** * Get the name of the event. * * @return string Name of event. */ public static function get_name() { return get_string('event:templatedisabled', 'quizaccess_seb'); } /** * Returns relevant URL. * @return \moodle_url */ public function get_url() { $params = [ 'id' => $this->objectid, 'action' => 'edit', ]; return new \moodle_url('/mod/quiz/accessrule/seb/template.php', $params); } /** * Returns non-localised event description with id's for admin use only. * * @return string Description. */ public function get_description() { return "The user with id '$this->userid' has disabled a template with id '$this->objectid'."; } /** * This is used when restoring course logs where it is required that we * map the objectid to it's new value in the new course. * * @return array Mapping of object id. */ public static function get_objectid_mapping(): array { return ['db' => 'quizaccess_seb_template', 'restore' => 'quizaccess_seb_template']; } /** * This is used when restoring course logs where it is required that we * map the information in 'other' to it's new value in the new course. * * @return array List of mapping of other ids. */ public static function get_other_mapping(): array { return []; } } PK9B\ @1accessrule/seb/classes/event/template_enabled.phpnu[. /** * Event for when a template is enabled. * * @package quizaccess_seb * @author Nicholas Hoobin * @copyright 2020 Catalyst IT * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace quizaccess_seb\event; use context_system; use core\event\base; use quizaccess_seb\template; defined('MOODLE_INTERNAL') || die(); /** * Event for when a template is enabled. * * @copyright 2020 Catalyst IT * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class template_enabled extends base { /** * Create event with strict parameters. * * Define strict parameters to create event with instead of relying on internal validation of array. Better code practice. * Easier for consumers of this class to know what data must be supplied and observers can have more trust in event data. * * @param template $template SEB template. * @param context_system $context Context system. * @return base */ public static function create_strict(template $template, context_system $context): base { global $USER; $tid = $template->get('id'); return self::create([ 'userid' => $USER->id, 'objectid' => $tid, 'context' => $context, ]); } /** * Initialize the event data. */ protected function init() { $this->data['objecttable'] = 'quizaccess_seb_template'; $this->data['crud'] = 'u'; $this->data['edulevel'] = self::LEVEL_PARTICIPATING; } /** * Get the name of the event. * * @return string Name of event. */ public static function get_name() { return get_string('event:templateenabled', 'quizaccess_seb'); } /** * Returns relevant URL. * @return \moodle_url */ public function get_url() { $params = [ 'id' => $this->objectid, 'action' => 'edit', ]; return new \moodle_url('/mod/quiz/accessrule/seb/template.php', $params); } /** * Returns non-localised event description with id's for admin use only. * * @return string Description. */ public function get_description() { return "The user with id '$this->userid' has enabled a template with id '$this->objectid'."; } /** * This is used when restoring course logs where it is required that we * map the objectid to it's new value in the new course. * * @return array Mapping of object id. */ public static function get_objectid_mapping(): array { return ['db' => 'quizaccess_seb_template', 'restore' => 'quizaccess_seb_template']; } /** * This is used when restoring course logs where it is required that we * map the information in 'other' to it's new value in the new course. * * @return array List of mapping of other ids. */ public static function get_other_mapping(): array { return []; } } PK9B\"W 1accessrule/seb/classes/event/template_deleted.phpnu[. /** * Event for when a template is deleted. * * @package quizaccess_seb * @author Nicholas Hoobin * @copyright 2020 Catalyst IT * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace quizaccess_seb\event; use context_system; use core\event\base; defined('MOODLE_INTERNAL') || die(); /** * Event for when a template is deleted. * * @copyright 2020 Catalyst IT * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class template_deleted extends base { /** * Create event with strict parameters. * * Define strict parameters to create event with instead of relying on internal validation of array. Better code practice. * Easier for consumers of this class to know what data must be supplied and observers can have more trust in event data. * * @param string $id The id of the template * @param context_system $context Context system. * @return base */ public static function create_strict(string $id, context_system $context): base { global $USER; return self::create([ 'userid' => $USER->id, 'objectid' => $id, 'context' => $context, ]); } /** * Initialize the event data. */ protected function init() { $this->data['objecttable'] = 'quizaccess_seb_template'; $this->data['crud'] = 'd'; $this->data['edulevel'] = self::LEVEL_PARTICIPATING; } /** * Get the name of the event. * * @return string Name of event. */ public static function get_name() { return get_string('event:templatedeleted', 'quizaccess_seb'); } /** * Returns relevant URL. * @return \moodle_url */ public function get_url() { return new \moodle_url('/mod/quiz/accessrule/seb/template.php'); } /** * Returns non-localised event description with id's for admin use only. * * @return string Description. */ public function get_description() { return "The user with id '$this->userid' has deleted a template with id '$this->objectid'."; } /** * This is used when restoring course logs where it is required that we * map the objectid to it's new value in the new course. * * @return array Mapping of object id. */ public static function get_objectid_mapping(): array { return ['db' => 'quizaccess_seb_template', 'restore' => 'quizaccess_seb_template']; } /** * This is used when restoring course logs where it is required that we * map the information in 'other' to it's new value in the new course. * * @return array List of mapping of other ids. */ public static function get_other_mapping(): array { return []; } } PK9B\UU1accessrule/seb/classes/event/access_prevented.phpnu[. /** * Event for when access to a quiz is prevented by this subplugin. * * @package quizaccess_seb * @author Andrew Madden * @copyright 2020 Catalyst IT * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace quizaccess_seb\event; use core\event\base; use quizaccess_seb\seb_access_manager; defined('MOODLE_INTERNAL') || die(); /** * Event for when access to a quiz is prevented by this subplugin. * * @copyright 2020 Catalyst IT * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class access_prevented extends base { /** * Create event with strict parameters. * * Define strict parameters to create event with instead of relying on internal validation of array. Better code practice. * Easier for consumers of this class to know what data must be supplied and observers can have more trust in event data. * * @param seb_access_manager $accessmanager Access manager. * @param string $reason Reason that access was prevented. * @param string|null $configkey A Safe Exam Browser config key. * @param string|null $browserexamkey A Safe Exam Browser browser exam key. * @return base */ public static function create_strict(seb_access_manager $accessmanager, string $reason, ?string $configkey = null, ?string $browserexamkey = null): base { global $USER; $other = []; $other['reason'] = $reason; $other['savedconfigkey'] = $accessmanager->get_valid_config_key(); $other['receivedconfigkey'] = !empty($configkey) ? $configkey : $accessmanager->get_received_config_key(); $other['receivedbrowserexamkey'] = !empty($browserexamkey) ? $browserexamkey : $accessmanager->get_received_browser_exam_key(); return self::create([ 'userid' => $USER->id, 'objectid' => $accessmanager->get_quiz()->get_quizid(), 'courseid' => $accessmanager->get_quiz()->get_courseid(), 'context' => $accessmanager->get_quiz()->get_context(), 'other' => $other, ]); } /** * Initialize the event data. */ protected function init() { $this->data['objecttable'] = 'quiz'; $this->data['crud'] = 'r'; $this->data['edulevel'] = self::LEVEL_PARTICIPATING; } /** * Get the name of the event. * * @return string Name of event. */ public static function get_name() { return get_string('event:accessprevented', 'quizaccess_seb'); } /** * Returns non-localised event description with id's for admin use only. * * @return string Description. */ public function get_description() { $description = "The user with id '$this->userid' has been prevented from accessing quiz with id '$this->objectid' by the " . "Safe Exam Browser access plugin. The reason was '{$this->other['reason']}'. " . "Expected config key: '{$this->other['savedconfigkey']}'. " . "Received config key: '{$this->other['receivedconfigkey']}'. " . "Received browser exam key: '{$this->other['receivedbrowserexamkey']}'."; return $description; } /** * This is used when restoring course logs where it is required that we * map the objectid to it's new value in the new course. * * @return array Mapping of object id. */ public static function get_objectid_mapping(): array { return ['db' => 'quiz', 'restore' => 'quiz']; } /** * This is used when restoring course logs where it is required that we * map the information in 'other' to it's new value in the new course. * * @return array List of mapping of other ids. */ public static function get_other_mapping(): array { return [ 'cmid' => ['db' => 'course_modules', 'restore' => 'course_modules'] ]; } } PK9B\{HΗ1accessrule/seb/classes/event/template_updated.phpnu[. /** * Event for when a template is updated. * * @package quizaccess_seb * @author Nicholas Hoobin * @copyright 2020 Catalyst IT * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace quizaccess_seb\event; use context_system; use core\event\base; use quizaccess_seb\template; defined('MOODLE_INTERNAL') || die(); /** * Event for when a template is updated. * * @copyright 2020 Catalyst IT * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class template_updated extends base { /** * Create event with strict parameters. * * Define strict parameters to create event with instead of relying on internal validation of array. Better code practice. * Easier for consumers of this class to know what data must be supplied and observers can have more trust in event data. * * @param template $template SEB template. * @param context_system $context Context system. * @return base */ public static function create_strict(template $template, context_system $context): base { global $USER; $tid = $template->get('id'); return self::create([ 'userid' => $USER->id, 'objectid' => $tid, 'context' => $context, ]); } /** * Initialize the event data. */ protected function init() { $this->data['objecttable'] = 'quizaccess_seb_template'; $this->data['crud'] = 'u'; $this->data['edulevel'] = self::LEVEL_PARTICIPATING; } /** * Get the name of the event. * * @return string Name of event. */ public static function get_name() { return get_string('event:templateupdated', 'quizaccess_seb'); } /** * Returns relevant URL. * @return \moodle_url */ public function get_url() { $params = [ 'id' => $this->objectid, 'action' => 'edit', ]; return new \moodle_url('/mod/quiz/accessrule/seb/template.php', $params); } /** * Returns non-localised event description with id's for admin use only. * * @return string Description. */ public function get_description() { return "The user with id '$this->userid' has updated a template with id '$this->objectid'."; } /** * This is used when restoring course logs where it is required that we * map the objectid to it's new value in the new course. * * @return array Mapping of object id. */ public static function get_objectid_mapping(): array { return ['db' => 'quizaccess_seb_template', 'restore' => 'quizaccess_seb_template']; } /** * This is used when restoring course logs where it is required that we * map the information in 'other' to it's new value in the new course. * * @return array List of mapping of other ids. */ public static function get_other_mapping(): array { return []; } } PK9B\aV5Z5Z,accessrule/seb/classes/seb_quiz_settings.phpnu[. /** * Entity model representing quiz settings for the seb plugin. * * @package quizaccess_seb * @author Andrew Madden * @copyright 2019 Catalyst IT * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace quizaccess_seb; use CFPropertyList\CFArray; use CFPropertyList\CFBoolean; use CFPropertyList\CFDictionary; use CFPropertyList\CFNumber; use CFPropertyList\CFString; use core\persistent; use lang_string; use moodle_exception; use moodle_url; defined('MOODLE_INTERNAL') || die(); /** * Entity model representing quiz settings for the seb plugin. * * @copyright 2020 Catalyst IT * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class seb_quiz_settings extends persistent { /** Table name for the persistent. */ const TABLE = 'quizaccess_seb_quizsettings'; /** @var property_list $plist The SEB config represented as a Property List object. */ private $plist; /** @var string $config The SEB config represented as a string. */ private $config; /** @var string $configkey The SEB config key represented as a string. */ private $configkey; /** * Return the definition of the properties of this model. * * @return array */ protected static function define_properties(): array { return [ 'quizid' => [ 'type' => PARAM_INT, ], 'cmid' => [ 'type' => PARAM_INT, ], 'templateid' => [ 'type' => PARAM_INT, 'default' => 0, ], 'requiresafeexambrowser' => [ 'type' => PARAM_INT, 'default' => 0, ], 'showsebtaskbar' => [ 'type' => PARAM_INT, 'default' => 1, 'null' => NULL_ALLOWED, ], 'showwificontrol' => [ 'type' => PARAM_INT, 'default' => 0, 'null' => NULL_ALLOWED, ], 'showreloadbutton' => [ 'type' => PARAM_INT, 'default' => 1, 'null' => NULL_ALLOWED, ], 'showtime' => [ 'type' => PARAM_INT, 'default' => 1, 'null' => NULL_ALLOWED, ], 'showkeyboardlayout' => [ 'type' => PARAM_INT, 'default' => 1, 'null' => NULL_ALLOWED, ], 'allowuserquitseb' => [ 'type' => PARAM_INT, 'default' => 1, 'null' => NULL_ALLOWED, ], 'quitpassword' => [ 'type' => PARAM_TEXT, 'default' => '', 'null' => NULL_ALLOWED, ], 'linkquitseb' => [ 'type' => PARAM_URL, 'default' => '', 'null' => NULL_ALLOWED, ], 'userconfirmquit' => [ 'type' => PARAM_INT, 'default' => 1, 'null' => NULL_ALLOWED, ], 'enableaudiocontrol' => [ 'type' => PARAM_INT, 'default' => 0, 'null' => NULL_ALLOWED, ], 'muteonstartup' => [ 'type' => PARAM_INT, 'default' => 0, 'null' => NULL_ALLOWED, ], 'allowcapturecamera' => [ 'type' => PARAM_INT, 'default' => 0, 'null' => NULL_ALLOWED, ], 'allowcapturemicrophone' => [ 'type' => PARAM_INT, 'default' => 0, 'null' => NULL_ALLOWED, ], 'allowspellchecking' => [ 'type' => PARAM_INT, 'default' => 0, 'null' => NULL_ALLOWED, ], 'allowreloadinexam' => [ 'type' => PARAM_INT, 'default' => 1, 'null' => NULL_ALLOWED, ], 'activateurlfiltering' => [ 'type' => PARAM_INT, 'default' => 0, 'null' => NULL_ALLOWED, ], 'filterembeddedcontent' => [ 'type' => PARAM_INT, 'default' => 0, 'null' => NULL_ALLOWED, ], 'expressionsallowed' => [ 'type' => PARAM_TEXT, 'default' => '', 'null' => NULL_ALLOWED, ], 'regexallowed' => [ 'type' => PARAM_TEXT, 'default' => '', 'null' => NULL_ALLOWED, ], 'expressionsblocked' => [ 'type' => PARAM_TEXT, 'default' => '', 'null' => NULL_ALLOWED, ], 'regexblocked' => [ 'type' => PARAM_TEXT, 'default' => '', 'null' => NULL_ALLOWED, ], 'showsebdownloadlink' => [ 'type' => PARAM_INT, 'default' => 1, 'null' => NULL_ALLOWED, ], 'allowedbrowserexamkeys' => [ 'type' => PARAM_TEXT, 'default' => '', 'null' => NULL_ALLOWED, ], ]; } /** * Return an instance by quiz id. * * This method gets data from cache before doing any DB calls. * * @param int $quizid Quiz id. * @return false|\quizaccess_seb\seb_quiz_settings */ public static function get_by_quiz_id(int $quizid) { if ($data = self::get_quiz_settings_cache()->get($quizid)) { return new static(0, $data); } return self::get_record(['quizid' => $quizid]); } /** * Return cached SEB config represented as a string by quiz ID. * * @param int $quizid Quiz id. * @return string|null */ public static function get_config_by_quiz_id(int $quizid): ?string { $config = self::get_config_cache()->get($quizid); if ($config !== false) { return $config; } $config = null; if ($settings = self::get_by_quiz_id($quizid)) { $config = $settings->get_config(); self::get_config_cache()->set($quizid, $config); } return $config; } /** * Return cached SEB config key by quiz ID. * * @param int $quizid Quiz id. * @return string|null */ public static function get_config_key_by_quiz_id(int $quizid): ?string { $configkey = self::get_config_key_cache()->get($quizid); if ($configkey !== false) { return $configkey; } $configkey = null; if ($settings = self::get_by_quiz_id($quizid)) { $configkey = $settings->get_config_key(); self::get_config_key_cache()->set($quizid, $configkey); } return $configkey; } /** * Return SEB config key cache instance. * * @return \cache_application */ private static function get_config_key_cache(): \cache_application { return \cache::make('quizaccess_seb', 'configkey'); } /** * Return SEB config cache instance. * * @return \cache_application */ private static function get_config_cache(): \cache_application { return \cache::make('quizaccess_seb', 'config'); } /** * Return quiz settings cache object, * * @return \cache_application */ private static function get_quiz_settings_cache(): \cache_application { return \cache::make('quizaccess_seb', 'quizsettings'); } /** * Adds the new record to the cache. */ protected function after_create() { $this->after_save(); } /** * Updates the cache record. * * @param bool $result */ protected function after_update($result) { $this->after_save(); } /** * Helper method to execute common stuff after create and update. */ private function after_save() { self::get_quiz_settings_cache()->set($this->get('quizid'), $this->to_record()); self::get_config_cache()->set($this->get('quizid'), $this->config); self::get_config_key_cache()->set($this->get('quizid'), $this->configkey); } /** * Removes unnecessary stuff from db. */ protected function before_delete() { $key = $this->get('quizid'); self::get_quiz_settings_cache()->delete($key); self::get_config_cache()->delete($key); self::get_config_key_cache()->delete($key); } /** * Validate the browser exam keys string. * * @param string $keys Newline separated browser exam keys. * @return true|lang_string If there is an error, an error string is returned. */ protected function validate_allowedbrowserexamkeys($keys) { $keys = $this->split_keys($keys); foreach ($keys as $i => $key) { if (!preg_match('~^[a-f0-9]{64}$~', $key)) { return new lang_string('allowedbrowserkeyssyntax', 'quizaccess_seb'); } } if (count($keys) != count(array_unique($keys))) { return new lang_string('allowedbrowserkeysdistinct', 'quizaccess_seb'); } return true; } /** * Get the browser exam keys as a pre-split array instead of just as a string. * * @return array */ protected function get_allowedbrowserexamkeys(): array { $keysstring = $this->raw_get('allowedbrowserexamkeys'); $keysstring = empty($keysstring) ? '' : $keysstring; return $this->split_keys($keysstring); } /** * Hook to execute before an update. * * Please note that at this stage the data has already been validated and therefore * any new data being set will not be validated before it is sent to the database. */ protected function before_update() { $this->before_save(); } /** * Hook to execute before a create. * * Please note that at this stage the data has already been validated and therefore * any new data being set will not be validated before it is sent to the database. */ protected function before_create() { $this->before_save(); } /** * As there is no hook for before both create and update, this function is called by both hooks. */ private function before_save() { // Set template to 0 if using anything different to template. if ($this->get('requiresafeexambrowser') != settings_provider::USE_SEB_TEMPLATE) { $this->set('templateid', 0); } // Process configs to make sure that all data is set correctly. $this->process_configs(); } /** * Before validate hook. */ protected function before_validate() { // Template can't be null. if (is_null($this->raw_get('templateid'))) { $this->set('templateid', 0); } } /** * Create or update the config string based on the current quiz settings. */ private function process_configs() { switch ($this->get('requiresafeexambrowser')) { case settings_provider::USE_SEB_NO: $this->process_seb_config_no(); break; case settings_provider::USE_SEB_CONFIG_MANUALLY: $this->process_seb_config_manually(); break; case settings_provider::USE_SEB_TEMPLATE: $this->process_seb_template(); break; case settings_provider::USE_SEB_UPLOAD_CONFIG: $this->process_seb_upload_config(); break; default: // Also settings_provider::USE_SEB_CLIENT_CONFIG. $this->process_seb_client_config(); } // Generate config key based on given SEB config. if (!empty($this->config)) { $this->configkey = config_key::generate($this->config)->get_hash(); } else { $this->configkey = null; } } /** * Return SEB config key. * * @return string|null */ public function get_config_key(): ?string { $this->process_configs(); return $this->configkey; } /** * Return string representation of the config. * * @return string|null */ public function get_config(): ?string { $this->process_configs(); return $this->config; } /** * Case for USE_SEB_NO. */ private function process_seb_config_no() { $this->config = null; } /** * Case for USE_SEB_CONFIG_MANUALLY. This creates a plist and applies all settings from the posted form, along with * some defaults. */ private function process_seb_config_manually() { // If at any point a configuration file has been uploaded and parsed, clear the settings. $this->plist = new property_list(); $this->process_bool_settings(); $this->process_quit_password_settings(); $this->process_quit_url_from_settings(); $this->process_url_filters(); $this->process_required_enforced_settings(); // One of the requirements for USE_SEB_CONFIG_MANUALLY is setting examSessionClearCookiesOnStart to false. $this->plist->set_or_update_value('examSessionClearCookiesOnStart', new CFBoolean(false)); $this->plist->set_or_update_value('allowPreferencesWindow', new CFBoolean(false)); $this->config = $this->plist->to_xml(); } /** * Case for USE_SEB_TEMPLATE. This creates a plist from the template uploaded, then applies the quit password * setting and some defaults. */ private function process_seb_template() { $template = template::get_record(['id' => $this->get('templateid')]); $this->plist = new property_list($template->get('content')); $this->process_bool_setting('allowuserquitseb'); $this->process_quit_password_settings(); $this->process_quit_url_from_template_or_config(); $this->process_required_enforced_settings(); $this->config = $this->plist->to_xml(); } /** * Case for USE_SEB_UPLOAD_CONFIG. This creates a plist from an uploaded configuration file, then applies the quiz * password settings and some defaults. */ private function process_seb_upload_config() { $file = settings_provider::get_module_context_sebconfig_file($this->get('cmid')); // If there was no file, create an empty plist so the rest of this wont explode. if (empty($file)) { throw new moodle_exception('noconfigfilefound', 'quizaccess_seb', '', $this->get('cmid')); } else { $this->plist = new property_list($file->get_content()); } $this->process_quit_url_from_template_or_config(); $this->process_required_enforced_settings(); $this->config = $this->plist->to_xml(); } /** * Case for USE_SEB_CLIENT_CONFIG. This creates an empty plist to remove the config stored. */ private function process_seb_client_config() { $this->config = null; } /** * Sets or updates some sensible default settings, these are the items 'startURL' and 'sendBrowserExamKey'. */ private function process_required_enforced_settings() { global $CFG; $quizurl = new moodle_url($CFG->wwwroot . "/mod/quiz/view.php", ['id' => $this->get('cmid')]); $this->plist->set_or_update_value('startURL', new CFString($quizurl->out(true))); $this->plist->set_or_update_value('sendBrowserExamKey', new CFBoolean(true)); // Use the modern WebView and JS API if the SEB version supports it. // Documentation: https://safeexambrowser.org/developer/seb-config-key.html . // "Set the key browserWindowWebView to the policy "Prefer Modern" (value 3)". $this->plist->set_or_update_value('browserWindowWebView', new CFNumber(3)); } /** * Use the boolean map to add Moodle boolean setting to config PList. */ private function process_bool_settings() { $settings = $this->to_record(); $map = $this->get_bool_seb_setting_map(); foreach ($settings as $setting => $value) { if (isset($map[$setting])) { $this->process_bool_setting($setting); } } } /** * Process provided single bool setting. * * @param string $name Setting name matching one from self::get_bool_seb_setting_map. */ private function process_bool_setting(string $name) { $map = $this->get_bool_seb_setting_map(); if (!isset($map[$name])) { throw new \coding_exception('Provided setting name can not be found in known bool settings'); } $enabled = $this->raw_get($name) == 1 ? true : false; $this->plist->set_or_update_value($map[$name], new CFBoolean($enabled)); } /** * Turn hashed quit password and quit link into PList strings and add to config PList. */ private function process_quit_password_settings() { $settings = $this->to_record(); if (!empty($settings->quitpassword) && is_string($settings->quitpassword)) { // Hash quit password. $hashedpassword = hash('SHA256', $settings->quitpassword); $this->plist->add_element_to_root('hashedQuitPassword', new CFString($hashedpassword)); } else if (!is_null($this->plist->get_element_value('hashedQuitPassword'))) { $this->plist->delete_element('hashedQuitPassword'); } } /** * Sets the quitURL if found in the seb_quiz_settings. */ private function process_quit_url_from_settings() { $settings = $this->to_record(); if (!empty($settings->linkquitseb) && is_string($settings->linkquitseb)) { $this->plist->set_or_update_value('quitURL', new CFString($settings->linkquitseb)); } } /** * Sets the quiz_setting's linkquitseb if a quitURL value was found in a template or uploaded config. */ private function process_quit_url_from_template_or_config() { // Does the plist (template or config file) have an existing quitURL? $quiturl = $this->plist->get_element_value('quitURL'); if (!empty($quiturl)) { $this->set('linkquitseb', $quiturl); } } /** * Turn return separated strings for URL filters into a PList array and add to config PList. */ private function process_url_filters() { $settings = $this->to_record(); // Create rules to each expression provided and add to config. $urlfilterrules = []; // Get all rules separated by newlines and remove empty rules. $expallowed = array_filter(explode(PHP_EOL, $settings->expressionsallowed)); $expblocked = array_filter(explode(PHP_EOL, $settings->expressionsblocked)); $regallowed = array_filter(explode(PHP_EOL, $settings->regexallowed)); $regblocked = array_filter(explode(PHP_EOL, $settings->regexblocked)); foreach ($expallowed as $rulestring) { $urlfilterrules[] = $this->create_filter_rule($rulestring, true, false); } foreach ($expblocked as $rulestring) { $urlfilterrules[] = $this->create_filter_rule($rulestring, false, false); } foreach ($regallowed as $rulestring) { $urlfilterrules[] = $this->create_filter_rule($rulestring, true, true); } foreach ($regblocked as $rulestring) { $urlfilterrules[] = $this->create_filter_rule($rulestring, false, true); } $this->plist->add_element_to_root('URLFilterRules', new CFArray($urlfilterrules)); } /** * Create a CFDictionary represeting a URL filter rule. * * @param string $rulestring The expression to filter with. * @param bool $allowed Allowed or blocked. * @param bool $isregex Regex or simple. * @return CFDictionary A PList dictionary. */ private function create_filter_rule(string $rulestring, bool $allowed, bool $isregex): CFDictionary { $action = $allowed ? 1 : 0; return new CFDictionary([ 'action' => new CFNumber($action), 'active' => new CFBoolean(true), 'expression' => new CFString(trim($rulestring)), 'regex' => new CFBoolean($isregex), ]); } /** * Map the settings that are booleans to the Safe Exam Browser config keys. * * @return array Moodle setting as key, SEB setting as value. */ private function get_bool_seb_setting_map(): array { return [ 'activateurlfiltering' => 'URLFilterEnable', 'allowspellchecking' => 'allowSpellCheck', 'allowreloadinexam' => 'browserWindowAllowReload', 'allowuserquitseb' => 'allowQuit', 'enableaudiocontrol' => 'audioControlEnabled', 'allowcapturecamera' => 'browserMediaCaptureCamera', 'allowcapturemicrophone' => 'browserMediaCaptureMicrophone', 'filterembeddedcontent' => 'URLFilterEnableContentFilter', 'muteonstartup' => 'audioMute', 'showkeyboardlayout' => 'showInputLanguage', 'showreloadbutton' => 'showReloadButton', 'showsebtaskbar' => 'showTaskBar', 'showtime' => 'showTime', 'showwificontrol' => 'allowWlan', 'userconfirmquit' => 'quitURLConfirm', ]; } /** * This helper method takes list of browser exam keys in a string and splits it into an array of separate keys. * * @param string|null $keys the allowed keys. * @return array of string, the separate keys. */ private function split_keys($keys): array { $keys = preg_split('~[ \t\n\r,;]+~', $keys ?? '', -1, PREG_SPLIT_NO_EMPTY); foreach ($keys as $i => $key) { $keys[$i] = strtolower($key); } return $keys; } } PK9B\m@@(accessrule/seb/classes/property_list.phpnu[. /** * Wrapper for CFPropertyList to handle low level iteration. * * @package quizaccess_seb * @author Andrew Madden * @copyright 2019 Catalyst IT * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace quizaccess_seb; use CFPropertyList\CFArray; use CFPropertyList\CFBoolean; use CFPropertyList\CFData; use CFPropertyList\CFDate; use CFPropertyList\CFDictionary; use CFPropertyList\CFNumber; use CFPropertyList\CFPropertyList; use CFPropertyList\CFString; use CFPropertyList\CFType; use \Collator; use \DateTime; /** * Wrapper for CFPropertyList to handle low level iteration. * * @copyright 2020 Catalyst IT * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class property_list { /** A random 4 character unicode string to replace backslashes during json_encode. */ private const BACKSLASH_SUBSTITUTE = "ؼҷҍԴ"; /** @var CFPropertyList $cfpropertylist */ private $cfpropertylist; /** * property_list constructor. * * @param string $xml A Plist XML string. */ public function __construct(string $xml = '') { $this->cfpropertylist = new CFPropertyList(); if (empty($xml)) { // If xml not provided, create a blank PList with root dictionary set up. $this->cfpropertylist->add(new CFDictionary([])); } else { // Parse the XML into a PList object. $this->cfpropertylist->parse($xml, CFPropertyList::FORMAT_XML); } } /** * Add a new element to the root dictionary element. * * @param string $key Key to assign to new element. * @param CFType $element The new element. May be a collection such as an array. */ public function add_element_to_root(string $key, CFType $element) { // Get the PList's root dictionary and add new element. $this->cfpropertylist->getValue()->add($key, $element); } /** * Get value of element identified by key. * * @param string $key Key of element. * @return mixed Value of element found, or null if none found. */ public function get_element_value(string $key) { $result = null; $this->plist_map( function($elvalue, $elkey, $parent) use ($key, &$result) { // Convert date to iso 8601 if date object. if ($key === $elkey) { $result = $elvalue->getValue(); } }, $this->cfpropertylist->getValue()); if (is_array($result)) { // Turn CFType elements in PHP elements. $result = $this->array_serialize_cftypes($result); } return $result; } /** * Update the value of any element with matching key. * * Only allow string, number and boolean elements to be updated. * * @param string $key Key of element to update. * @param mixed $value Value to update element with. */ public function update_element_value(string $key, $value) { if (is_array($value)) { throw new \invalid_parameter_exception('Use update_element_array to update a collection.'); } $this->plist_map( function($elvalue, $elkey, $parent) use ($key, $value) { // Set new value. if ($key === $elkey) { $element = $parent->get($elkey); // Limit update to boolean and strings types, and check value matches expected type. if (($element instanceof CFString && is_string($value)) || ($element instanceof CFNumber && is_numeric($value)) || ($element instanceof CFBoolean && is_bool($value))) { $element->setValue($value); } else { throw new \invalid_parameter_exception( 'Only string, number and boolean elements can be updated, or value type does not match element type: ' . get_class($element)); } } }, $this->cfpropertylist->getValue()); } /** * Update the array of any dict or array element with matching key. * * Will replace array. * * @param string $key Key of element to update. * @param array $value Array to update element with. */ public function update_element_array(string $key, array $value) { // Validate new array. foreach ($value as $element) { // If any element is not a CFType instance, then throw exception. if (!($element instanceof CFType)) { throw new \invalid_parameter_exception('New array must only contain CFType objects.'); } } $this->plist_map( function($elvalue, $elkey, $parent) use ($key, $value) { if ($key === $elkey) { $element = $parent->get($elkey); // Replace existing element with new element and array but same key. if ($element instanceof CFDictionary) { $parent->del($elkey); $parent->add($elkey, new CFDictionary($value)); } else if ($element instanceof CFArray) { $parent->del($elkey); $parent->add($elkey, new CFArray($value)); } } }, $this->cfpropertylist->getValue()); } /** * Delete any element with a matching key. * * @param string $key Key of element to delete. */ public function delete_element(string $key) { $this->plist_map( function($elvalue, $elkey, $parent) use ($key) { // Convert date to iso 8601 if date object. if ($key === $elkey) { $parent->del($key); } }, $this->cfpropertylist->getValue()); } /** * Helper function to either set or update a CF type value to the plist. * * @param string $key * @param CFType $input */ public function set_or_update_value(string $key, CFType $input) { $value = $this->get_element_value($key); if (empty($value)) { $this->add_element_to_root($key, $input); } else { $this->update_element_value($key, $input->getValue()); } } /** * Convert the PList to XML. * * @return string XML ready for creating an XML file. */ public function to_xml(): string { return $this->cfpropertylist->toXML(); } /** * Return a JSON representation of the PList. The JSON is constructed to be used to generate a SEB Config Key. * * See the developer documention for SEB for more information on the requirements on generating a SEB Config Key. * https://safeexambrowser.org/developer/seb-config-key.html * * 1. Don't add any whitespace or line formatting to the SEB-JSON string. * 2. Don't add character escaping (also backshlashes "\" as found in URL filter rules should not be escaped). * 3. All elements from the plist XML must be ordered (alphabetically sorted) by their key names. Use a * recursive method to apply ordering also to nested dictionaries contained in the root-level dictionary and in * arrays. Use non-localized (culture invariant), non-ASCII value based case insensitive ordering. For example the * key allowWlan comes before allowWLAN. Cocoa/Obj-C and .NET/C# usually use this case * insensitive ordering as default, but PHP for example doesn't. * 4. Remove empty elements (key/value). Current versions of SEB clients should anyways not generate empty * dictionaries, but this was possible with outdated versions. If config files have been generated that time, such * elements might still be around. * 5. All string elements must be UTF8 encoded. * 6. Base16 strings should use lower-case a-f characters, even though this isn't relevant in the current * implementation of the Config Key calculation. * 7. plist XML elements must be converted to Base64 strings. * 8. plist XML elements must be converted to ISO 8601 formatted strings. * * @return string A json encoded string. */ public function to_json(): string { // Create a clone of the PList, so main list isn't mutated. $jsonplist = new CFPropertyList(); $jsonplist->parse($this->cfpropertylist->toXML(), CFPropertyList::FORMAT_XML); // Pass root dict to recursively convert dates to ISO 8601 format, encode strings to UTF-8, // lock data to Base 64 encoding and remove empty dictionaries. $this->prepare_plist_for_json_encoding($jsonplist->getValue()); // Serialize PList to array. $plistarray = $jsonplist->toArray(); // Sort array alphabetically by key using case insensitive, natural sorting. See point 3 for more information. $plistarray = $this->array_sort($plistarray); // Encode in JSON with following rules from SEB docs. // 1. Don't add any whitespace or line formatting to the SEB-JSON string. // 2. Don't add unicode or slash escaping. $json = json_encode($plistarray, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); // There is no way to prevent json_encode from escaping backslashes. We replace each backslash with a unique string // prior to encoding in prepare_plist_for_json_encoding(). We can then replace the substitute with a single backslash. $json = str_replace(self::BACKSLASH_SUBSTITUTE, "\\", $json); return $json; } /** * Recursively convert PList date values from unix to iso 8601 format, and ensure strings are UTF 8 encoded. * * This will mutate the PList. */ /** * Recursively convert PList date values from unix to iso 8601 format, and ensure strings are UTF 8 encoded. * * This will mutate the PList. * @param \Iterator $root The root element of the PList. Must be a dictionary or array. */ private function prepare_plist_for_json_encoding($root) { $this->plist_map( function($value, $key, $parent) { // Convert date to ISO 8601 if date object. if ($value instanceof CFDate) { $date = DateTime::createFromFormat('U', $value->getValue()); $date->setTimezone(new \DateTimeZone('UTC')); // Zulu timezone a.k.a. UTC+00. $isodate = $date->format('c'); $value->setValue($isodate); } // Make sure strings are UTF 8 encoded. if ($value instanceof CFString) { // As literal backslashes will be lost during encoding, we must replace them with a unique substitute to be // reverted after JSON encoding. $string = str_replace("\\", self::BACKSLASH_SUBSTITUTE, $value->getValue()); $value->setValue(mb_convert_encoding($string, 'UTF-8')); } // Data should remain base 64 encoded, so convert to base encoded string for export. Otherwise // CFData will decode the data when serialized. if ($value instanceof CFData) { $data = trim($value->getCodedValue()); $parent->del($key); $parent->add($key, new CFString($data)); } // Empty dictionaries should be removed. if ($value instanceof CFDictionary && empty($value->getValue())) { $parent->del($key); } }, $root); } /** * Iterate through the PList elements, and call the callback on each. * * @param callable $callback A callback function called for every element. * @param \Iterator $root The root element of the PList. Must be a dictionary or array. * @param bool $recursive Whether the function should traverse dicts and arrays recursively. */ private function plist_map(callable $callback, \Iterator $root, bool $recursive = true) { $root->rewind(); while ($root->valid()) { $value = $root->current(); $key = $root->key(); // Recursively traverse all dicts and arrays if flag is true. if ($recursive && $value instanceof \Iterator) { $this->plist_map($callback, $value); } // Callback function called for every element. $callback($value, $key, $root); $root->next(); } } /** * Recursively sort array alphabetically by key. * * @link https://safeexambrowser.org/developer/seb-config-key.html * * @param array $array Top level array to process. * @return array Processed array. */ private function array_sort(array $array) { foreach ($array as $key => $value) { if (is_array($value)) { $array[$key] = $this->array_sort($array[$key]); } } // Sort assoc array. From SEB docs: // // All elements from the plist XML must be ordered (alphabetically sorted) by their key names. Use // a recursive method to apply ordering also to nested dictionaries contained in the root-level dictionary // and in arrays. Use non-localized (culture invariant), non-ASCII value based case insensitive ordering. // For example the key allowWlan comes before allowWLAN. Cocoa/Obj-C and .NET/C# // usually use this case insensitive ordering as default, but PHP for example doesn't. if ($this->is_associative_array($array)) { // Note this is a pragmatic solution as none of the native PHP *sort method appear to sort strings that // differ only in case (e.g. ["allowWLAN", "allowWlan"] is expected to have the lower version first). $keys = array_keys($array); (new Collator('root'))->asort($keys); // Use Unicode Collation Algorithm (UCA). $original = $array; $array = []; foreach ($keys as $key) { $array[$key] = $original[$key]; } } return $array; } /** * Recursively remove empty arrays. * * @param array $array Top level array to process. * @return array Processed array. */ private function array_remove_empty_arrays(array $array) { foreach ($array as $key => $value) { if (is_array($value)) { $array[$key] = $this->array_remove_empty_arrays($array[$key]); } // Remove empty arrays. if (is_array($array[$key]) && empty($array[$key])) { unset($array[$key]); } } return $array; } /** * If an array contains CFType objects, wrap array in a CFDictionary to allow recursive serialization of data * into a standard PHP array. * * @param array $array Array containing CFType objects. * @return array Standard PHP array. */ private function array_serialize_cftypes(array $array): array { $array = new CFDictionary($array); // Convert back to CFDictionary so serialization is recursive. return $array->toArray(); // Serialize. } /** * Check if an array is associative or sequential. * * @param array $array Array to check. * @return bool False if not associative. */ private function is_associative_array(array $array) { if (empty($array)) { return false; } // Check that all keys are not sequential integers starting from 0 (Which is what PHP arrays have behind the scenes.) return array_keys($array) !== range(0, count($array) - 1); } } PK9B\ohYY#accessrule/seb/classes/template.phpnu[. /** * Entity model representing template settings for the seb plugin. * * @package quizaccess_seb * @author Nicholas Hoobin * @author Dmitrii Metelkin * @copyright 2020 Catalyst IT * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace quizaccess_seb; use core\persistent; defined('MOODLE_INTERNAL') || die(); /** * Entity model representing template settings for the seb plugin. * * @copyright 2020 Catalyst IT * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class template extends persistent { /** Table name for the persistent. */ const TABLE = 'quizaccess_seb_template'; /** @var property_list $plist The SEB config represented as a Property List object. */ private $plist; /** * Return the definition of the properties of this model. * * @return array */ protected static function define_properties() { return [ 'name' => [ 'type' => PARAM_TEXT, 'default' => '', ], 'description' => [ 'type' => PARAM_TEXT, 'default' => '', ], 'content' => [ 'type' => PARAM_RAW, ], 'enabled' => [ 'type' => PARAM_INT, 'default' => 0, ], 'sortorder' => [ 'type' => PARAM_INT, 'default' => 0, ], ]; } /** * Hook to execute before an update. * * Please note that at this stage the data has already been validated and therefore * any new data being set will not be validated before it is sent to the database. */ protected function before_update() { $this->before_save(); } /** * Hook to execute before a create. * * Please note that at this stage the data has already been validated and therefore * any new data being set will not be validated before it is sent to the database. */ protected function before_create() { $this->before_save(); } /** * As there is no hook for before both create and update, this function is called by both hooks. */ private function before_save() { $this->plist = new property_list($this->get('content')); $this->set('content', $this->plist->to_xml()); } /** * Validate template content. * * @param string $content Content string to validate. * * @return bool|\lang_string */ protected function validate_content(string $content) { if (helper::is_valid_seb_config($content)) { return true; } else { return new \lang_string('invalidtemplate', 'quizaccess_seb'); } } /** * Check if we can delete the template. * * @return bool */ public function can_delete(): bool { $result = true; if ($this->get('id')) { $settings = seb_quiz_settings::get_records(['templateid' => $this->get('id')]); $result = empty($settings); } return $result; } } PK9B\2BFpkk)accessrule/seb/templates/loading.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 quizaccess_seb/loading Loading container. Example context (json): { "extraclasses": "class" } }} PK9B\7K accessrule/seb/amd/src/view.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 . /** * Manage the quiz views. * * @module quizaccess_seb/view * @author Andrew Madden * @copyright 2021 Catalyst IT * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ import Notification from "core/notification"; import * as Templates from "core/templates"; import * as Str from "core/str"; import ModalAlert from "core/local/modal/alert"; /** @var SELECTOR List of CSS selectors. */ const SELECTOR = { MAIN: '#region-main', LOADING: '.seb-loading', }; /** @var Template List of mustache templates. */ const TEMPLATE = { LOADING: 'quizaccess_seb/loading', }; /** * Manages view when access has been granted. */ export const allowAccess = () => { window.location.reload(); }; /** * Add an alert to page to inform that Safe Exam Browser access is being checked. * * @return {Promise} */ export const addLoadingAlert = () => { return Templates.render(TEMPLATE.LOADING, {}).then((html, js) => { const alertRegion = window.document.querySelector(SELECTOR.MAIN); return Templates.prependNodeContents(alertRegion, html, js); }).catch(Notification.exception); }; /** * Remove the Safe Exam Browser access check alert from the page. */ export const clearLoadingAlert = () => { const alert = window.document.querySelector(SELECTOR.LOADING); if (alert) { Templates.replaceNode(alert, '', ''); } }; /** * Display validation failed modal. */ export const showValidationFailedModal = () => { ModalAlert.create({ title: Str.get_string('sebkeysvalidationfailed', 'quizaccess_seb'), body: Str.get_string('invalidkeys', 'quizaccess_seb'), large: false, show: true, }).catch(Notification.exception); }; PK9B\.accessrule/seb/amd/src/validate_quiz_access.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 . /** * Validate Safe Exam Browser access keys. * * @module quizaccess_seb/validate_quiz_access * @author Andrew Madden * @copyright 2021 Catalyst IT * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ import Ajax from 'core/ajax'; import Config from 'core/config'; import Notification from "core/notification"; import * as View from 'quizaccess_seb/view'; // SafeExamBrowser object will be automatically initialized if using the SafeExamBrowser application. window.SafeExamBrowser = window.SafeExamBrowser || null; /** * Once the keys are fetched, action checking access. * * @param {init} cmid Value of course module id of the quiz. * @param {boolean} autoreconfigure Value of Moodle setting: quizaccess_seb/autoreconfigureseb. */ const safeExamBrowserKeysUpdated = (cmid, autoreconfigure = false) => { // Action opening up the quiz. isQuizAccessValid(cmid).then((response) => { // Show the alert for an extra second to allow user to see it. setTimeout(View.clearLoadingAlert, 1000); if (response.configkey && response.browserexamkey) { View.allowAccess(); } else { // If autoreconfigureseb is enabled, attempt to reconfigure page with quiz settings. if (autoreconfigure === true && response.configkey === false) { reconfigureSafeExamBrowser(cmid); } setTimeout(View.showValidationFailedModal, 1000); } return response; }).catch(err => { Notification.exception(err); }); }; /** * Validate keys in Moodle backend. * * @param {init} cmid Value of course module id of the quiz. * @return {Promise} */ const isQuizAccessValid = (cmid) => { const request = { methodname: 'quizaccess_seb_validate_quiz_keys', args: { cmid: cmid, url: window.location.href, configkey: window.SafeExamBrowser.security.configKey, browserexamkey: window.SafeExamBrowser.security.browserExamKey }, }; return Ajax.call([request])[0]; }; /** * Check if the key is not yet set. * * @param {string} key config key or browser exam key. * @return {boolean} */ const isKeyEmpty = (key) => { // If the SafeExamBrowser object is defined, the default 'empty' value of the configKey and browserExamKey is ':'. return key === ":"; }; /** * Reload Safe Exam Browser with current quiz configuration. * * @param {init} cmid Value of course module id of the quiz. */ const reconfigureSafeExamBrowser = (cmid) => { const domain = Config.wwwroot.replace(/^http/i, 'seb'); const redirecturl = domain + '/mod/quiz/accessrule/seb/config.php?cmid=' + cmid; document.location.replace(redirecturl); }; /** * Initialize the process of fetching the keys. * * @param {init} cmid Value of course module id of the quiz. * @param {boolean} autoreconfigure Value of Moodle setting: quizaccess_seb/autoreconfigureseb. */ export const init = async(cmid, autoreconfigure = false) => { // If the SafeExamBrowser object is instantiated, try and use it to fetch the access keys. if (window.SafeExamBrowser !== null) { await View.addLoadingAlert(); // If the SEB keys are already set, we can call our callback directly. if (!isKeyEmpty(window.SafeExamBrowser.security.configKey) || !isKeyEmpty(window.SafeExamBrowser.security.browserExamKey)) { safeExamBrowserKeysUpdated(cmid, autoreconfigure); } else { window.SafeExamBrowser.security.updateKeys(safeExamBrowserKeysUpdated); } } }; PK9B\oN)accessrule/seb/amd/src/managetemplates.jsnu[/** * Template management code. * * @module quizaccess_seb/managetemplates * @copyright 2020 Dmitrii Metelkin */ define( ['jquery', 'core/ajax', 'core/str', 'core/notification'], function($, ajax, str, notification) { var manager = { /** * Confirm removal of the specified template. * * @method removeTemplate * @param {EventFacade} e The EventFacade */ removeTemplate: function(e) { e.preventDefault(); var targetUrl = $(e.currentTarget).attr('href'); str.get_strings([ { key: 'confirmtemplateremovaltitle', component: 'quizaccess_seb' }, { key: 'confirmtemplateremovalquestion', component: 'quizaccess_seb' }, { key: 'yes', component: 'moodle' }, { key: 'no', component: 'moodle' } ]) .then(function(s) { notification.confirm(s[0], s[1], s[2], s[3], function() { window.location = targetUrl; }); return; }) .catch(); }, /** * Setup the template management UI. * * @method setup */ setup: function() { $('body').delegate('[data-action="delete"]', 'click', manager.removeTemplate); } }; return /** @alias module:quizaccess_seb/managetemplates */ { /** * Setup the template management UI. * * @method setup */ setup: manager.setup }; }); PK9B\HD 3accessrule/seb/amd/build/managetemplates.min.js.mapnu[{"version":3,"file":"managetemplates.min.js","sources":["../src/managetemplates.js"],"sourcesContent":["/**\n * Template management code.\n *\n * @module quizaccess_seb/managetemplates\n * @copyright 2020 Dmitrii Metelkin \n */\ndefine(\n ['jquery', 'core/ajax', 'core/str', 'core/notification'],\n function($, ajax, str, notification) {\n var manager = {\n /**\n * Confirm removal of the specified template.\n *\n * @method removeTemplate\n * @param {EventFacade} e The EventFacade\n */\n removeTemplate: function(e) {\n e.preventDefault();\n var targetUrl = $(e.currentTarget).attr('href');\n str.get_strings([\n {\n key: 'confirmtemplateremovaltitle',\n component: 'quizaccess_seb'\n },\n {\n key: 'confirmtemplateremovalquestion',\n component: 'quizaccess_seb'\n },\n {\n key: 'yes',\n component: 'moodle'\n },\n {\n key: 'no',\n component: 'moodle'\n }\n ])\n .then(function(s) {\n notification.confirm(s[0], s[1], s[2], s[3], function() {\n window.location = targetUrl;\n });\n\n return;\n })\n .catch();\n },\n\n /**\n * Setup the template management UI.\n *\n * @method setup\n */\n setup: function() {\n $('body').delegate('[data-action=\"delete\"]', 'click', manager.removeTemplate);\n }\n };\n\n return /** @alias module:quizaccess_seb/managetemplates */ {\n /**\n * Setup the template management UI.\n *\n * @method setup\n */\n setup: manager.setup\n };\n });\n"],"names":["define","$","ajax","str","notification","manager","removeTemplate","e","preventDefault","targetUrl","currentTarget","attr","get_strings","key","component","then","s","confirm","window","location","catch","setup","delegate"],"mappings":"AAMAA,wCACI,CAAC,SAAU,YAAa,WAAY,sBACpC,SAASC,EAAGC,KAAMC,IAAKC,kBACfC,QAAU,CAOVC,eAAgB,SAASC,GACrBA,EAAEC,qBACEC,UAAYR,EAAEM,EAAEG,eAAeC,KAAK,QACxCR,IAAIS,YAAY,CACZ,CACIC,IAAY,8BACZC,UAAY,kBAEhB,CACID,IAAY,iCACZC,UAAY,kBAEhB,CACID,IAAY,MACZC,UAAY,UAEhB,CACID,IAAY,KACZC,UAAY,YAGnBC,MAAK,SAASC,GACXZ,aAAaa,QAAQD,EAAE,GAAIA,EAAE,GAAIA,EAAE,GAAIA,EAAE,IAAI,WACzCE,OAAOC,SAAWV,gBAKzBW,SAQLC,MAAO,WACHpB,EAAE,QAAQqB,SAAS,yBAA0B,QAASjB,QAAQC,wBAIX,CAMvDe,MAAOhB,QAAQgB"}PK9B\i8accessrule/seb/amd/build/validate_quiz_access.min.js.mapnu[{"version":3,"file":"validate_quiz_access.min.js","sources":["../src/validate_quiz_access.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 * Validate Safe Exam Browser access keys.\n *\n * @module quizaccess_seb/validate_quiz_access\n * @author Andrew Madden \n * @copyright 2021 Catalyst IT\n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport Ajax from 'core/ajax';\nimport Config from 'core/config';\nimport Notification from \"core/notification\";\nimport * as View from 'quizaccess_seb/view';\n\n// SafeExamBrowser object will be automatically initialized if using the SafeExamBrowser application.\nwindow.SafeExamBrowser = window.SafeExamBrowser || null;\n\n/**\n * Once the keys are fetched, action checking access.\n *\n * @param {init} cmid Value of course module id of the quiz.\n * @param {boolean} autoreconfigure Value of Moodle setting: quizaccess_seb/autoreconfigureseb.\n */\nconst safeExamBrowserKeysUpdated = (cmid, autoreconfigure = false) => {\n // Action opening up the quiz.\n isQuizAccessValid(cmid).then((response) => {\n // Show the alert for an extra second to allow user to see it.\n setTimeout(View.clearLoadingAlert, 1000);\n\n if (response.configkey && response.browserexamkey) {\n View.allowAccess();\n } else {\n // If autoreconfigureseb is enabled, attempt to reconfigure page with quiz settings.\n if (autoreconfigure === true && response.configkey === false) {\n reconfigureSafeExamBrowser(cmid);\n }\n setTimeout(View.showValidationFailedModal, 1000);\n }\n\n return response;\n }).catch(err => {\n Notification.exception(err);\n });\n};\n\n/**\n * Validate keys in Moodle backend.\n *\n * @param {init} cmid Value of course module id of the quiz.\n * @return {Promise}\n */\nconst isQuizAccessValid = (cmid) => {\n const request = {\n methodname: 'quizaccess_seb_validate_quiz_keys',\n args: {\n cmid: cmid,\n url: window.location.href,\n configkey: window.SafeExamBrowser.security.configKey,\n browserexamkey: window.SafeExamBrowser.security.browserExamKey\n },\n };\n\n return Ajax.call([request])[0];\n};\n\n/**\n * Check if the key is not yet set.\n *\n * @param {string} key config key or browser exam key.\n * @return {boolean}\n */\nconst isKeyEmpty = (key) => {\n // If the SafeExamBrowser object is defined, the default 'empty' value of the configKey and browserExamKey is ':'.\n return key === \":\";\n};\n\n/**\n * Reload Safe Exam Browser with current quiz configuration.\n *\n * @param {init} cmid Value of course module id of the quiz.\n */\nconst reconfigureSafeExamBrowser = (cmid) => {\n const domain = Config.wwwroot.replace(/^http/i, 'seb');\n const redirecturl = domain + '/mod/quiz/accessrule/seb/config.php?cmid=' + cmid;\n document.location.replace(redirecturl);\n};\n\n/**\n * Initialize the process of fetching the keys.\n *\n * @param {init} cmid Value of course module id of the quiz.\n * @param {boolean} autoreconfigure Value of Moodle setting: quizaccess_seb/autoreconfigureseb.\n */\nexport const init = async(cmid, autoreconfigure = false) => {\n // If the SafeExamBrowser object is instantiated, try and use it to fetch the access keys.\n if (window.SafeExamBrowser !== null) {\n await View.addLoadingAlert();\n // If the SEB keys are already set, we can call our callback directly.\n\n if (!isKeyEmpty(window.SafeExamBrowser.security.configKey) || !isKeyEmpty(window.SafeExamBrowser.security.browserExamKey)) {\n safeExamBrowserKeysUpdated(cmid, autoreconfigure);\n } else {\n window.SafeExamBrowser.security.updateKeys(safeExamBrowserKeysUpdated);\n }\n }\n};\n"],"names":["window","SafeExamBrowser","safeExamBrowserKeysUpdated","cmid","autoreconfigure","isQuizAccessValid","then","response","setTimeout","View","clearLoadingAlert","configkey","browserexamkey","allowAccess","reconfigureSafeExamBrowser","showValidationFailedModal","catch","err","exception","request","methodname","args","url","location","href","security","configKey","browserExamKey","Ajax","call","isKeyEmpty","key","redirecturl","Config","wwwroot","replace","document","async","addLoadingAlert","updateKeys"],"mappings":";;;;;;;;02BA8BAA,OAAOC,gBAAkBD,OAAOC,iBAAmB,WAQ7CC,2BAA6B,SAACC,UAAMC,wEAEtCC,kBAAkBF,MAAMG,MAAMC,WAE1BC,WAAWC,KAAKC,kBAAmB,KAE/BH,SAASI,WAAaJ,SAASK,eAC/BH,KAAKI,gBAGmB,IAApBT,kBAAmD,IAAvBG,SAASI,WACrCG,2BAA2BX,MAE/BK,WAAWC,KAAKM,0BAA2B,MAGxCR,YACRS,OAAMC,4BACQC,UAAUD,SAUzBZ,kBAAqBF,aACjBgB,QAAU,CACZC,WAAY,oCACZC,KAAM,CACFlB,KAAMA,KACNmB,IAAKtB,OAAOuB,SAASC,KACrBb,UAAWX,OAAOC,gBAAgBwB,SAASC,UAC3Cd,eAAgBZ,OAAOC,gBAAgBwB,SAASE,wBAIjDC,cAAKC,KAAK,CAACV,UAAU,IAS1BW,WAAcC,KAED,MAARA,IAQLjB,2BAA8BX,aAE1B6B,YADSC,gBAAOC,QAAQC,QAAQ,SAAU,OACnB,4CAA8ChC,KAC3EiC,SAASb,SAASY,QAAQH,4BASVK,eAAMlC,UAAMC,wEAEG,OAA3BJ,OAAOC,wBACDQ,KAAK6B,kBAGNR,WAAW9B,OAAOC,gBAAgBwB,SAASC,YAAeI,WAAW9B,OAAOC,gBAAgBwB,SAASE,gBAGtG3B,OAAOC,gBAAgBwB,SAASc,WAAWrC,4BAF3CA,2BAA2BC,KAAMC"}PK9B\h)/accessrule/seb/amd/build/managetemplates.min.jsnu[define("quizaccess_seb/managetemplates",["jquery","core/ajax","core/str","core/notification"],(function($,ajax,str,notification){var manager={removeTemplate:function(e){e.preventDefault();var targetUrl=$(e.currentTarget).attr("href");str.get_strings([{key:"confirmtemplateremovaltitle",component:"quizaccess_seb"},{key:"confirmtemplateremovalquestion",component:"quizaccess_seb"},{key:"yes",component:"moodle"},{key:"no",component:"moodle"}]).then((function(s){notification.confirm(s[0],s[1],s[2],s[3],(function(){window.location=targetUrl}))})).catch()},setup:function(){$("body").delegate('[data-action="delete"]',"click",manager.removeTemplate)}};return{setup:manager.setup}})); //# sourceMappingURL=managetemplates.min.js.mapPK9B\JUm (accessrule/seb/amd/build/view.min.js.mapnu[{"version":3,"file":"view.min.js","sources":["../src/view.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 * Manage the quiz views.\n *\n * @module quizaccess_seb/view\n * @author Andrew Madden \n * @copyright 2021 Catalyst IT\n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport Notification from \"core/notification\";\nimport * as Templates from \"core/templates\";\nimport * as Str from \"core/str\";\nimport ModalAlert from \"core/local/modal/alert\";\n\n/** @var SELECTOR List of CSS selectors. */\nconst SELECTOR = {\n MAIN: '#region-main',\n LOADING: '.seb-loading',\n};\n\n/** @var Template List of mustache templates. */\nconst TEMPLATE = {\n LOADING: 'quizaccess_seb/loading',\n};\n\n/**\n * Manages view when access has been granted.\n */\nexport const allowAccess = () => {\n window.location.reload();\n};\n\n/**\n * Add an alert to page to inform that Safe Exam Browser access is being checked.\n *\n * @return {Promise}\n */\nexport const addLoadingAlert = () => {\n return Templates.render(TEMPLATE.LOADING, {}).then((html, js) => {\n const alertRegion = window.document.querySelector(SELECTOR.MAIN);\n return Templates.prependNodeContents(alertRegion, html, js);\n }).catch(Notification.exception);\n};\n\n/**\n * Remove the Safe Exam Browser access check alert from the page.\n */\nexport const clearLoadingAlert = () => {\n const alert = window.document.querySelector(SELECTOR.LOADING);\n if (alert) {\n Templates.replaceNode(alert, '', '');\n }\n};\n\n/**\n * Display validation failed modal.\n */\nexport const showValidationFailedModal = () => {\n ModalAlert.create({\n title: Str.get_string('sebkeysvalidationfailed', 'quizaccess_seb'),\n body: Str.get_string('invalidkeys', 'quizaccess_seb'),\n large: false,\n show: true,\n }).catch(Notification.exception);\n};\n"],"names":["SELECTOR","TEMPLATE","window","location","reload","Templates","render","then","html","js","alertRegion","document","querySelector","prependNodeContents","catch","Notification","exception","alert","replaceNode","create","title","Str","get_string","body","large","show"],"mappings":";;;;;;;;8VA8BMA,cACI,eADJA,iBAEO,eAIPC,iBACO,8CAMc,KACvBC,OAAOC,SAASC,mCAQW,IACpBC,UAAUC,OAAOL,iBAAkB,IAAIM,MAAK,CAACC,KAAMC,YAChDC,YAAcR,OAAOS,SAASC,cAAcZ,sBAC3CK,UAAUQ,oBAAoBH,YAAaF,KAAMC,OACzDK,MAAMC,sBAAaC,sCAMO,WACvBC,MAAQf,OAAOS,SAASC,cAAcZ,kBACxCiB,OACAZ,UAAUa,YAAYD,MAAO,GAAI,wCAOA,oBAC1BE,OAAO,CACdC,MAAOC,IAAIC,WAAW,0BAA2B,kBACjDC,KAAMF,IAAIC,WAAW,cAAe,kBACpCE,OAAO,EACPC,MAAM,IACPX,MAAMC,sBAAaC"}PK9B\k^ ^ 4accessrule/seb/amd/build/validate_quiz_access.min.jsnu[define("quizaccess_seb/validate_quiz_access",["exports","core/ajax","core/config","core/notification","quizaccess_seb/view"],(function(_exports,_ajax,_config,_notification,View){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}} /** * Validate Safe Exam Browser access keys. * * @module quizaccess_seb/validate_quiz_access * @author Andrew Madden * @copyright 2021 Catalyst IT * @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),_config=_interopRequireDefault(_config),_notification=_interopRequireDefault(_notification),View=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}(View),window.SafeExamBrowser=window.SafeExamBrowser||null;const safeExamBrowserKeysUpdated=function(cmid){let autoreconfigure=arguments.length>1&&void 0!==arguments[1]&&arguments[1];isQuizAccessValid(cmid).then((response=>(setTimeout(View.clearLoadingAlert,1e3),response.configkey&&response.browserexamkey?View.allowAccess():(!0===autoreconfigure&&!1===response.configkey&&reconfigureSafeExamBrowser(cmid),setTimeout(View.showValidationFailedModal,1e3)),response))).catch((err=>{_notification.default.exception(err)}))},isQuizAccessValid=cmid=>{const request={methodname:"quizaccess_seb_validate_quiz_keys",args:{cmid:cmid,url:window.location.href,configkey:window.SafeExamBrowser.security.configKey,browserexamkey:window.SafeExamBrowser.security.browserExamKey}};return _ajax.default.call([request])[0]},isKeyEmpty=key=>":"===key,reconfigureSafeExamBrowser=cmid=>{const redirecturl=_config.default.wwwroot.replace(/^http/i,"seb")+"/mod/quiz/accessrule/seb/config.php?cmid="+cmid;document.location.replace(redirecturl)};_exports.init=async function(cmid){let autoreconfigure=arguments.length>1&&void 0!==arguments[1]&&arguments[1];null!==window.SafeExamBrowser&&(await View.addLoadingAlert(),isKeyEmpty(window.SafeExamBrowser.security.configKey)&&isKeyEmpty(window.SafeExamBrowser.security.browserExamKey)?window.SafeExamBrowser.security.updateKeys(safeExamBrowserKeysUpdated):safeExamBrowserKeysUpdated(cmid,autoreconfigure))}})); //# sourceMappingURL=validate_quiz_access.min.js.mapPK9B\! 7 7 $accessrule/seb/amd/build/view.min.jsnu[define("quizaccess_seb/view",["exports","core/notification","core/templates","core/str","core/local/modal/alert"],(function(_exports,_notification,Templates,Str,_alert){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}function _interopRequireDefault(obj){return obj&&obj.__esModule?obj:{default:obj}} /** * Manage the quiz views. * * @module quizaccess_seb/view * @author Andrew Madden * @copyright 2021 Catalyst IT * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.showValidationFailedModal=_exports.clearLoadingAlert=_exports.allowAccess=_exports.addLoadingAlert=void 0,_notification=_interopRequireDefault(_notification),Templates=_interopRequireWildcard(Templates),Str=_interopRequireWildcard(Str),_alert=_interopRequireDefault(_alert);const SELECTOR_MAIN="#region-main",SELECTOR_LOADING=".seb-loading",TEMPLATE_LOADING="quizaccess_seb/loading";_exports.allowAccess=()=>{window.location.reload()};_exports.addLoadingAlert=()=>Templates.render(TEMPLATE_LOADING,{}).then(((html,js)=>{const alertRegion=window.document.querySelector(SELECTOR_MAIN);return Templates.prependNodeContents(alertRegion,html,js)})).catch(_notification.default.exception);_exports.clearLoadingAlert=()=>{const alert=window.document.querySelector(SELECTOR_LOADING);alert&&Templates.replaceNode(alert,"","")};_exports.showValidationFailedModal=()=>{_alert.default.create({title:Str.get_string("sebkeysvalidationfailed","quizaccess_seb"),body:Str.get_string("invalidkeys","quizaccess_seb"),large:!1,show:!0}).catch(_notification.default.exception)}})); //# sourceMappingURL=view.min.js.mapPK9B\g2!]!]accessrule/seb/rule.phpnu[. use mod_quiz\local\access_rule_base; use mod_quiz\quiz_attempt; use quizaccess_seb\seb_access_manager; use quizaccess_seb\seb_quiz_settings; use quizaccess_seb\settings_provider; use quizaccess_seb\event\access_prevented; /** * Implementation of the quizaccess_seb plugin. * * @package quizaccess_seb * @author Andrew Madden * @author Dmitrii Metelkin * @copyright 2019 Catalyst IT * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class quizaccess_seb extends access_rule_base { /** @var seb_access_manager $accessmanager Instance to manage the access to the quiz for this plugin. */ private $accessmanager; /** * Create an instance of this rule for a particular quiz. * * @param \mod_quiz\quiz_settings $quizobj information about the quiz in question. * @param int $timenow the time that should be considered as 'now'. * @param seb_access_manager $accessmanager the quiz accessmanager. */ public function __construct(\mod_quiz\quiz_settings $quizobj, int $timenow, seb_access_manager $accessmanager) { parent::__construct($quizobj, $timenow); $this->accessmanager = $accessmanager; } /** * Return an appropriately configured instance of this rule, if it is applicable * to the given quiz, otherwise return null. * * @param \mod_quiz\quiz_settings $quizobj information about the quiz in question. * @param int $timenow the time that should be considered as 'now'. * @param bool $canignoretimelimits whether the current user is exempt from * time limits by the mod/quiz:ignoretimelimits capability. * @return access_rule_base|null the rule, if applicable, else null. */ public static function make(\mod_quiz\quiz_settings $quizobj, $timenow, $canignoretimelimits) { $accessmanager = new seb_access_manager($quizobj); // If Safe Exam Browser is not required, this access rule is not applicable. if (!$accessmanager->seb_required()) { return null; } return new self($quizobj, $timenow, $accessmanager); } /** * Add any fields that this rule requires to the quiz settings form. This * method is called from {@link mod_quiz_mod_form::definition()}, while the * security section is being built. * * @param mod_quiz_mod_form $quizform the quiz settings form that is being built. * @param MoodleQuickForm $mform the wrapped MoodleQuickForm. */ public static function add_settings_form_fields(mod_quiz_mod_form $quizform, MoodleQuickForm $mform) { settings_provider::add_seb_settings_fields($quizform, $mform); } /** * Validate the data from any form fields added using {@link add_settings_form_fields()}. * * If the managing user cannot configure SEB by either lack of permissions or locked * settings, then the form fields will be frozen and no validation will be done. * * @param array $errors the errors found so far. * @param array $data the submitted form data. * @param array $files information about any uploaded files. * @param mod_quiz_mod_form $quizform the quiz form object. * @return array $errors the updated $errors array. */ public static function validate_settings_form_fields(array $errors, array $data, $files, mod_quiz_mod_form $quizform): array { $quizid = $data['instance']; $cmid = $data['coursemodule']; $context = $quizform->get_context(); if (!settings_provider::can_configure_seb($context)) { return $errors; } if (settings_provider::is_seb_settings_locked($quizid)) { return $errors; } if (settings_provider::is_conflicting_permissions($context)) { return $errors; } $settings = settings_provider::filter_plugin_settings((object) $data); // Validate basic settings using persistent class. $quizsettings = (new seb_quiz_settings())->from_record($settings); // Set non-form fields. $quizsettings->set('quizid', $quizid); $quizsettings->set('cmid', $cmid); $quizsettings->validate(); // Add any errors to list. foreach ($quizsettings->get_errors() as $name => $error) { $name = settings_provider::add_prefix($name); // Re-add prefix to match form element. $errors[$name] = $error->out(); } // Edge case for filemanager_sebconfig. if ($quizsettings->get('requiresafeexambrowser') == settings_provider::USE_SEB_UPLOAD_CONFIG) { $errorvalidatefile = settings_provider::validate_draftarea_configfile($data['filemanager_sebconfigfile']); if (!empty($errorvalidatefile)) { $errors['filemanager_sebconfigfile'] = $errorvalidatefile; } } // Edge case to force user to select a template. if ($quizsettings->get('requiresafeexambrowser') == settings_provider::USE_SEB_TEMPLATE) { if (empty($data['seb_templateid'])) { $errors['seb_templateid'] = get_string('invalidtemplate', 'quizaccess_seb'); } } if ($quizsettings->get('requiresafeexambrowser') != settings_provider::USE_SEB_NO) { // Global settings may be active which require a quiz password to be set if using SEB. if (!empty(get_config('quizaccess_seb', 'quizpasswordrequired')) && empty($data['quizpassword'])) { $errors['quizpassword'] = get_string('passwordnotset', 'quizaccess_seb'); } } return $errors; } /** * Save any submitted settings when the quiz settings form is submitted. This * is called from {@link quiz_after_add_or_update()} in lib.php. * * @param stdClass $quiz the data from the quiz form, including $quiz->id * which is the id of the quiz being saved. */ public static function save_settings($quiz) { $context = context_module::instance($quiz->coursemodule); if (!settings_provider::can_configure_seb($context)) { return; } if (settings_provider::is_seb_settings_locked($quiz->id)) { return; } if (settings_provider::is_conflicting_permissions($context)) { return; } $cm = get_coursemodule_from_instance('quiz', $quiz->id, $quiz->course, false, MUST_EXIST); $settings = settings_provider::filter_plugin_settings($quiz); $settings->quizid = $quiz->id; $settings->cmid = $cm->id; // Get existing settings or create new settings if none exist. $quizsettings = seb_quiz_settings::get_by_quiz_id($quiz->id); if (empty($quizsettings)) { $quizsettings = new seb_quiz_settings(0, $settings); } else { $settings->id = $quizsettings->get('id'); $quizsettings->from_record($settings); } // Process uploaded files if required. if ($quizsettings->get('requiresafeexambrowser') == settings_provider::USE_SEB_UPLOAD_CONFIG) { $draftitemid = file_get_submitted_draft_itemid('filemanager_sebconfigfile'); settings_provider::save_filemanager_sebconfigfile_draftarea($draftitemid, $cm->id); } else { settings_provider::delete_uploaded_config_file($cm->id); } // Save or delete settings. if ($quizsettings->get('requiresafeexambrowser') != settings_provider::USE_SEB_NO) { $quizsettings->save(); } else if ($quizsettings->get('id')) { $quizsettings->delete(); } } /** * Delete any rule-specific settings when the quiz is deleted. This is called * from {@link quiz_delete_instance()} in lib.php. * * @param stdClass $quiz the data from the database, including $quiz->id * which is the id of the quiz being deleted. */ public static function delete_settings($quiz) { $quizsettings = seb_quiz_settings::get_by_quiz_id($quiz->id); // Check that there are existing settings. if ($quizsettings !== false) { $quizsettings->delete(); } } /** * Return the bits of SQL needed to load all the settings from all the access * plugins in one DB query. The easiest way to understand what you need to do * here is probably to read the code of {@see \mod_quiz\access_manager::load_settings()}. * * If you have some settings that cannot be loaded in this way, then you can * use the {@link get_extra_settings()} method instead, but that has * performance implications. * * @param int $quizid the id of the quiz we are loading settings for. This * can also be accessed as quiz.id in the SQL. (quiz is a table alisas for {quiz}.) * @return array with three elements: * 1. fields: any fields to add to the select list. These should be alised * if neccessary so that the field name starts the name of the plugin. * 2. joins: any joins (should probably be LEFT JOINS) with other tables that * are needed. * 3. params: array of placeholder values that are needed by the SQL. You must * used named placeholders, and the placeholder names should start with the * plugin name, to avoid collisions. */ public static function get_settings_sql($quizid): array { return [ 'seb.requiresafeexambrowser AS seb_requiresafeexambrowser, ' . 'seb.showsebtaskbar AS seb_showsebtaskbar, ' . 'seb.showwificontrol AS seb_showwificontrol, ' . 'seb.showreloadbutton AS seb_showreloadbutton, ' . 'seb.showtime AS seb_showtime, ' . 'seb.showkeyboardlayout AS seb_showkeyboardlayout, ' . 'seb.allowuserquitseb AS seb_allowuserquitseb, ' . 'seb.quitpassword AS seb_quitpassword, ' . 'seb.linkquitseb AS seb_linkquitseb, ' . 'seb.userconfirmquit AS seb_userconfirmquit, ' . 'seb.enableaudiocontrol AS seb_enableaudiocontrol, ' . 'seb.muteonstartup AS seb_muteonstartup, ' . 'seb.allowcapturecamera AS seb_allowcapturecamera, ' . 'seb.allowcapturemicrophone AS seb_allowcapturemicrophone, ' . 'seb.allowspellchecking AS seb_allowspellchecking, ' . 'seb.allowreloadinexam AS seb_allowreloadinexam, ' . 'seb.activateurlfiltering AS seb_activateurlfiltering, ' . 'seb.filterembeddedcontent AS seb_filterembeddedcontent, ' . 'seb.expressionsallowed AS seb_expressionsallowed, ' . 'seb.regexallowed AS seb_regexallowed, ' . 'seb.expressionsblocked AS seb_expressionsblocked, ' . 'seb.regexblocked AS seb_regexblocked, ' . 'seb.allowedbrowserexamkeys AS seb_allowedbrowserexamkeys, ' . 'seb.showsebdownloadlink AS seb_showsebdownloadlink, ' . 'sebtemplate.id AS seb_templateid ' , 'LEFT JOIN {quizaccess_seb_quizsettings} seb ON seb.quizid = quiz.id ' . 'LEFT JOIN {quizaccess_seb_template} sebtemplate ON seb.templateid = sebtemplate.id ' , [] ]; } /** * Whether the user should be blocked from starting a new attempt or continuing * an attempt now. * * @return string false if access should be allowed, a message explaining the * reason if access should be prevented. */ public function prevent_access() { global $PAGE; if (!$this->accessmanager->seb_required()) { return false; } if ($this->accessmanager->can_bypass_seb()) { return false; } // If the rule is active, enforce a secure view whilst taking the quiz. $PAGE->set_pagelayout('secure'); $this->prevent_display_blocks(); // Access has previously been validated for this session and quiz. if ($this->accessmanager->validate_session_access()) { return false; } if (!$this->accessmanager->validate_basic_header()) { access_prevented::create_strict($this->accessmanager, $this->get_reason_text('not_seb'))->trigger(); return $this->get_require_seb_error_message(); } if (!$this->accessmanager->validate_config_key()) { if ($this->accessmanager->should_redirect_to_seb_config_link()) { $this->accessmanager->redirect_to_seb_config_link(); } access_prevented::create_strict($this->accessmanager, $this->get_reason_text('invalid_config_key'))->trigger(); return $this->get_invalid_key_error_message(); } if (!$this->accessmanager->validate_browser_exam_key()) { access_prevented::create_strict($this->accessmanager, $this->get_reason_text('invalid_browser_key'))->trigger(); return $this->get_invalid_key_error_message(); } // Set the state of the access for this Moodle session. $this->accessmanager->set_session_access(true); return false; } /** * Returns a list of finished attempts for the current user. * * @return array */ private function get_user_finished_attempts(): array { global $USER; return quiz_get_user_attempts( $this->quizobj->get_quizid(), $USER->id, quiz_attempt::FINISHED, false ); } /** * Prevent block displaying as configured. */ private function prevent_display_blocks() { global $PAGE; if ($PAGE->has_set_url() && $PAGE->url == $this->quizobj->view_url()) { $attempts = $this->get_user_finished_attempts(); // Don't display blocks before starting an attempt. if (empty($attempts) && !get_config('quizaccess_seb', 'displayblocksbeforestart')) { $PAGE->blocks->show_only_fake_blocks(); } // Don't display blocks after finishing an attempt. if (!empty($attempts) && !get_config('quizaccess_seb', 'displayblockswhenfinished')) { $PAGE->blocks->show_only_fake_blocks(); } } } /** * Returns reason for access prevention as a text. * * @param string $identifier Reason string identifier. * @return string */ private function get_reason_text(string $identifier): string { if (in_array($identifier, ['not_seb', 'invalid_config_key', 'invalid_browser_key'])) { return get_string($identifier, 'quizaccess_seb'); } return get_string('unknown_reason', 'quizaccess_seb'); } /** * Return error message when a SEB key is not valid. * * @return string */ private function get_invalid_key_error_message(): string { // Return error message with download link and links to get the seb config. if ($this->accessmanager->is_using_seb()) { return get_string('invalidkeys', 'quizaccess_seb') . $this->display_buttons($this->get_action_buttons()); } return $this->display_buttons($this->get_action_buttons()); } /** * Return error message when a SEB browser is not used. * * @return string */ private function get_require_seb_error_message(): string { $message = get_string('clientrequiresseb', 'quizaccess_seb'); if ($this->should_display_download_seb_link()) { $message .= $this->display_buttons($this->get_download_seb_button()); } // Return error message with download link. return $message; } /** * Helper function to display an Exit Safe Exam Browser button if configured to do so and attempts are > 0. * * @return string empty or a button which has the configured seb quit link. */ private function get_quit_button(): string { $quitbutton = ''; if (empty($this->get_user_finished_attempts())) { return $quitbutton; } // Only display if the link has been configured and attempts are greater than 0. if (!empty($this->quiz->seb_linkquitseb)) { $quitbutton = html_writer::link( $this->quiz->seb_linkquitseb, get_string('exitsebbutton', 'quizaccess_seb'), ['class' => 'btn btn-secondary'] ); } return $quitbutton; } /** * Information, such as might be shown on the quiz view page, relating to this restriction. * There is no obligation to return anything. If it is not appropriate to tell students * about this rule, then just return ''. * * @return mixed a message, or array of messages, explaining the restriction * (may be '' if no message is appropriate). */ public function description(): array { global $PAGE; $messages = [get_string('sebrequired', 'quizaccess_seb')]; // Display download SEB config link for those who can bypass using SEB. if ($this->accessmanager->can_bypass_seb() && $this->accessmanager->should_validate_config_key()) { $messages[] = $this->display_buttons($this->get_download_config_button()); } // Those with higher level access will be able to see the button if they've made an attempt. if (!$this->prevent_access()) { $messages[] = $this->display_buttons($this->get_quit_button()); } else { $PAGE->requires->js_call_amd('quizaccess_seb/validate_quiz_access', 'init', [$this->quiz->cmid, (bool)get_config('quizaccess_seb', 'autoreconfigureseb')]); } return $messages; } /** * Sets up the attempt (review or summary) page with any special extra * properties required by this rule. * * @param moodle_page $page the page object to initialise. */ public function setup_attempt_page($page) { $page->set_title($this->quizobj->get_course()->shortname . ': ' . $page->title); $page->set_popup_notification_allowed(false); // Prevent message notifications. $page->set_heading($page->title); $page->set_pagelayout('secure'); } /** * This is called when the current attempt at the quiz is finished. */ public function current_attempt_finished() { $this->accessmanager->clear_session_access(); } /** * Prepare buttons HTML code for being displayed on the screen. * * @param string $buttonshtml Html string of the buttons. * @param string $class Optional CSS class (or classes as space-separated list) * @param array $attributes Optional other attributes as array * * @return string HTML code of the provided buttons. */ private function display_buttons(string $buttonshtml, $class = '', ?array $attributes = null): string { $html = ''; if (!empty($buttonshtml)) { $html = html_writer::div($buttonshtml, $class, $attributes); } return $html; } /** * Get buttons to prompt user to download SEB or config file or launch SEB. * * @return string Html block of all action buttons. */ private function get_action_buttons(): string { $buttons = ''; if ($this->should_display_download_seb_link()) { $buttons .= $this->get_download_seb_button(); } // Get config for displaying links. $linkconfig = explode(',', get_config('quizaccess_seb', 'showseblinks')); // Display links to download config/launch SEB only if required. if ($this->accessmanager->should_validate_config_key()) { if (in_array('seb', $linkconfig)) { $buttons .= $this->get_launch_seb_button(); } if (in_array('http', $linkconfig)) { $buttons .= $this->get_download_config_button(); } } return $buttons; } /** * Get a button to download SEB. * * @return string A link to download SafeExam Browser. */ private function get_download_seb_button(): string { global $OUTPUT; $button = ''; if (!empty($this->get_seb_download_url())) { $sebdownloadlink = $this->get_seb_download_url(); $button = html_writer::start_tag('div', ['class' => 'singlebutton']); $button .= html_writer::link($sebdownloadlink, get_string('sebdownloadbutton', 'quizaccess_seb'), ['class' => 'btn btn-secondary', 'target' => '_blank']); $button .= html_writer::end_tag('div'); } return $button; } /** * Get a button to launch Safe Exam Browser. * * @return string A link to launch Safe Exam Browser. */ private function get_launch_seb_button(): string { // Rendering as a href and not as button in a form to circumvent browser warnings for sending to URL with unknown protocol. $seblink = \quizaccess_seb\link_generator::get_link($this->quiz->cmid, true, is_https()); $buttonlink = html_writer::start_tag('div', ['class' => 'singlebutton']); $buttonlink .= html_writer::link($seblink, get_string('seblinkbutton', 'quizaccess_seb'), ['class' => 'btn btn-secondary', 'title' => get_string('seblinkbutton', 'quizaccess_seb')]); $buttonlink .= html_writer::end_tag('div'); return $buttonlink; } /** * Get a button to download Safe Exam Browser config. * * @return string A link to launch Safe Exam Browser. */ private function get_download_config_button(): string { // Rendering as a href and not as button in a form to circumvent browser warnings for sending to URL with unknown protocol. $httplink = \quizaccess_seb\link_generator::get_link($this->quiz->cmid, false, is_https()); $buttonlink = html_writer::start_tag('div', ['class' => 'singlebutton']); $buttonlink .= html_writer::link($httplink, get_string('httplinkbutton', 'quizaccess_seb'), ['class' => 'btn btn-secondary', 'title' => get_string('httplinkbutton', 'quizaccess_seb')]); $buttonlink .= html_writer::end_tag('div'); return $buttonlink; } /** * Returns SEB download URL. * * @return string */ private function get_seb_download_url(): string { return get_config('quizaccess_seb', 'downloadlink'); } /** * Check if we should display a link to download Safe Exam Browser. * * @return bool */ private function should_display_download_seb_link(): bool { return !empty($this->quiz->seb_showsebdownloadlink); } } PK9B\O##$accessrule/seb/tests/helper_test.phpnu[. namespace quizaccess_seb; defined('MOODLE_INTERNAL') || die(); require_once(__DIR__ . '/test_helper_trait.php'); /** * PHPUnit tests for helper class. * * @package quizaccess_seb * @author Dmitrii Metelkin * @copyright 2020 Catalyst IT * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class helper_test extends \advanced_testcase { use \quizaccess_seb_test_helper_trait; /** * Test that we can check valid seb string. */ public function test_is_valid_seb_config(): void { $validseb = " showTaskBarallowWlanshowReloadButton" . "showTimeshowInputLanguageallowQuit" . "quitURLConfirmaudioControlEnabledaudioMute" . "browserMediaCaptureCamerabrowserMediaCaptureMicrophone" . "allowSpellCheckbrowserWindowAllowReloadURLFilterEnable" . "URLFilterEnableContentFilterhashedQuitPassword" . "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08URLFilterRules" . "action1activeexpression" . "test.comregex" . "sendBrowserExamKey\n"; $invalidseb = 'Invalid seb'; $emptyseb = ''; $this->assertTrue(\quizaccess_seb\helper::is_valid_seb_config($validseb)); $this->assertFalse(\quizaccess_seb\helper::is_valid_seb_config($invalidseb)); $this->assertFalse(\quizaccess_seb\helper::is_valid_seb_config($emptyseb)); } /** * Test that we can get seb file headers. */ public function test_get_seb_file_headers(): void { $expiretime = 1582767914; $headers = \quizaccess_seb\helper::get_seb_file_headers($expiretime); $this->assertCount(5, $headers); $this->assertEquals('Cache-Control: private, max-age=1, no-transform', $headers[0]); $this->assertEquals('Expires: Thu, 27 Feb 2020 01:45:14 GMT', $headers[1]); $this->assertEquals('Pragma: no-cache', $headers[2]); $this->assertEquals('Content-Disposition: attachment; filename=config.seb', $headers[3]); $this->assertEquals('Content-Type: application/seb', $headers[4]); } /** * Test that the course module must exist to get a seb config file content. */ public function test_can_not_get_config_content_with_invalid_cmid(): void { $this->resetAfterTest(); $user = $this->getDataGenerator()->create_user(); $course = $this->getDataGenerator()->create_course(); $this->getDataGenerator()->enrol_user($user->id, $course->id); $this->setUser($user); // Log user in. $this->expectException(\dml_exception::class); $this->expectExceptionMessage("Can't find data record in database. (SELECT cm.*, m.name, md.name AS modname \n" . " FROM {course_modules} cm\n" . " JOIN {modules} md ON md.id = cm.module\n" . " JOIN {quiz} m ON m.id = cm.instance\n" . " \n" . " WHERE cm.id = :cmid AND md.name = :modulename\n" . " \n" . "[array (\n" . " 'cmid' => '999',\n" . " 'modulename' => 'quiz',\n" .')])'); \quizaccess_seb\helper::get_seb_config_content('999'); } /** * Test that the user must be enrolled to get seb config content. */ public function test_can_not_get_config_content_when_user_not_enrolled_in_course(): void { $this->resetAfterTest(); $this->setAdminUser(); $course = $this->getDataGenerator()->create_course(); $quiz = $this->create_test_quiz($course, \quizaccess_seb\settings_provider::USE_SEB_CONFIG_MANUALLY); $user = $this->getDataGenerator()->create_user(); $this->setUser($user); // Log user in. $this->expectException(\moodle_exception::class); $this->expectExceptionMessage('Unsupported redirect detected, script execution terminated'); \quizaccess_seb\helper::get_seb_config_content($quiz->cmid); } /** * Test that if SEB quiz settings can't be found, a seb config content won't be provided. */ public function test_can_not_get_config_content_if_config_not_found_for_cmid(): void { $this->resetAfterTest(); $this->setAdminUser(); $course = $this->getDataGenerator()->create_course(); $quiz = $this->create_test_quiz($course); $user = $this->getDataGenerator()->create_user(); $this->getDataGenerator()->enrol_user($user->id, $course->id); $this->setUser($user); // Log user in. $this->expectException(\moodle_exception::class); $this->expectExceptionMessage("No SEB config could be found for quiz with cmid: $quiz->cmid"); \quizaccess_seb\helper::get_seb_config_content($quiz->cmid); } /** * That that if config is empty for a quiz, a seb config content won't be provided. */ public function test_can_not_get_config_content_if_config_empty(): void { $this->resetAfterTest(); $this->setAdminUser(); $course = $this->getDataGenerator()->create_course(); $quiz = $this->create_test_quiz($course, \quizaccess_seb\settings_provider::USE_SEB_NO); $user = $this->getDataGenerator()->create_user(); $this->getDataGenerator()->enrol_user($user->id, $course->id); $this->setUser($user); // Log user in. $this->expectException(\moodle_exception::class); $this->expectExceptionMessage("No SEB config could be found for quiz with cmid: $quiz->cmid"); \quizaccess_seb\helper::get_seb_config_content($quiz->cmid); } /** * Test config content is provided successfully. */ public function test_config_provided(): void { $this->resetAfterTest(); $this->setAdminUser(); $course = $this->getDataGenerator()->create_course(); $quiz = $this->create_test_quiz($course, \quizaccess_seb\settings_provider::USE_SEB_CONFIG_MANUALLY); $user = $this->getDataGenerator()->create_user(); $this->getDataGenerator()->enrol_user($user->id, $course->id); $this->setUser($user); // Log user in. $config = \quizaccess_seb\helper::get_seb_config_content($quiz->cmid); $url = new \moodle_url("/mod/quiz/view.php", ['id' => $quiz->cmid]); $this->assertEquals("\n" . "\n" . "showTaskBarallowWlan" . "showReloadButtonshowTimeshowInputLanguage" . "allowQuitquitURLConfirmaudioControlEnabled" . "audioMutebrowserMediaCaptureCamerabrowserMediaCaptureMicrophone" . "allowSpellCheckbrowserWindowAllowReload" . "URLFilterEnableURLFilterEnableContentFilter" . "URLFilterRulesstartURL$url" . "sendBrowserExamKeybrowserWindowWebView3" . "examSessionClearCookiesOnStart" . "allowPreferencesWindow\n", $config); } } PK9B\D:accessrule/seb/tests/fixtures/JSON_unencrypted_mac_001.txtnu[{"additionalResources":[],"allowBrowsingBackForward":false,"allowDictation":false,"allowDictionaryLookup":false,"allowDisplayMirroring":false,"allowDownUploads":false,"allowedDisplayBuiltin":true,"allowedDisplaysMaxNumber":1,"allowFlashFullscreen":false,"allowiOSBetaVersionNumber":0,"allowiOSVersionNumberMajor":9,"allowiOSVersionNumberMinor":3,"allowiOSVersionNumberPatch":5,"allowPDFPlugIn":false,"allowPreferencesWindow":true,"allowQuit":true,"allowScreenSharing":false,"allowSiri":false,"allowSpellCheck":false,"allowSwitchToApplications":false,"allowUserAppFolderInstall":false,"allowUserSwitching":true,"allowVideoCapture":false,"allowVirtualMachine":false,"allowWlan":false,"blacklistURLFilter":"","blockPopUpWindows":false,"browserMediaAutoplay":false,"browserMessagingPingTime":120000,"browserMessagingSocket":"ws:\localhost:8706","browserScreenKeyboard":false,"browserURLSalt":true,"browserUserAgent":"","browserUserAgentiOS":0,"browserUserAgentiOSCustom":"","browserUserAgentMac":0,"browserUserAgentMacCustom":"","browserUserAgentWinDesktopMode":0,"browserUserAgentWinDesktopModeCustom":"","browserUserAgentWinTouchMode":0,"browserUserAgentWinTouchModeCustom":"","browserUserAgentWinTouchModeIPad":"Mozilla/5.0 (iPad; CPU OS 11_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/11.0 Mobile/15E148 Safari/604.1","browserViewMode":0,"browserWindowAllowReload":true,"browserWindowShowURL":0,"chooseFileToUploadPolicy":0,"configFileCreateIdentity":false,"configFileEncryptUsingIdentity":false,"configKeySalt":"","createNewDesktop":true,"detectStoppedProcess":true,"downloadAndOpenSebConfig":true,"downloadDirectoryOSX":"/Users/andrewmadden/Downloads","downloadDirectoryWin":"Downloads","downloadPDFFiles":false,"embeddedCertificates":[],"enableAltEsc":false,"enableAltF4":false,"enableAltMouseWheel":false,"enableAltTab":true,"enableAppSwitcherCheck":true,"enableBrowserWindowToolbar":false,"enableCtrlEsc":false,"enableDrawingEditor":false,"enableEsc":true,"enableF1":true,"enableF10":true,"enableF11":true,"enableF12":true,"enableF2":true,"enableF3":true,"enableF4":true,"enableF5":true,"enableF6":true,"enableF7":true,"enableF8":true,"enableF9":true,"enableJava":false,"enableJavaScript":true,"enableLogging":true,"enablePlugIns":true,"enablePrintScreen":false,"enablePrivateClipboard":true,"enableRightMouse":true,"enableSebBrowser":true,"enableStartMenu":false,"enableTouchExit":false,"enableZoomPage":true,"enableZoomText":true,"examKeySalt":"4iLONVAc1yKWLxbe6Vbf2vWYQO6zIRAobKgc3f25LH8=","examSessionClearSessionCookies":true,"examSessionReconfigureAllow":false,"examSessionReconfigureConfigURL":"","exitKey1":2,"exitKey2":10,"exitKey3":5,"forceAppFolderInstall":true,"hashedAdminPassword":"","hashedQuitPassword":"","hideBrowserWindowToolbar":false,"hookKeys":true,"ignoreExitKeys":true,"ignoreQuitPassword":false,"insideSebEnableChangeAPassword":false,"insideSebEnableEaseOfAccess":false,"insideSebEnableLockThisComputer":false,"insideSebEnableLogOff":false,"insideSebEnableNetworkConnectionSelector":false,"insideSebEnableShutDown":false,"insideSebEnableStartTaskManager":false,"insideSebEnableSwitchUser":false,"insideSebEnableVmWareClientShade":false,"killExplorerShell":false,"logDirectoryOSX":"","logDirectoryWin":"","logLevel":4,"logSendingRequiresAdminPassword":false,"mainBrowserWindowHeight":"100%","mainBrowserWindowPositioning":1,"mainBrowserWindowWidth":"100%","minMacOSVersion":4,"mobileAllowInlineMediaPlayback":true,"mobileAllowPictureInPictureMediaPlayback":false,"mobileAllowQRCodeConfig":false,"mobileAllowSingleAppMode":false,"mobileCompactAllowInlineMediaPlayback":false,"mobileEnableASAM":true,"mobileEnableGuidedAccessLinkTransform":false,"mobilePreventAutoLock":true,"mobileShowSettings":false,"mobileStatusBarAppearance":1,"mobileStatusBarAppearanceExtended":1,"mobileSupportedFormFactorsCompact":true,"mobileSupportedFormFactorsNonTelephonyCompact":true,"mobileSupportedFormFactorsRegular":true,"mobileSupportedScreenOrientationsCompactLandscapeLeft":true,"mobileSupportedScreenOrientationsCompactLandscapeRight":true,"mobileSupportedScreenOrientationsCompactPortrait":true,"mobileSupportedScreenOrientationsCompactPortraitUpsideDown":false,"mobileSupportedScreenOrientationsRegularLandscapeLeft":true,"mobileSupportedScreenOrientationsRegularLandscapeRight":true,"mobileSupportedScreenOrientationsRegularPortrait":true,"mobileSupportedScreenOrientationsRegularPortraitUpsideDown":true,"monitorProcesses":true,"newBrowserWindowAllowReload":true,"newBrowserWindowByLinkBlockForeign":false,"newBrowserWindowByLinkHeight":"100%","newBrowserWindowByLinkPolicy":1,"newBrowserWindowByLinkPositioning":2,"newBrowserWindowByLinkWidth":"1000","newBrowserWindowByScriptBlockForeign":false,"newBrowserWindowByScriptPolicy":2,"newBrowserWindowNavigation":true,"newBrowserWindowShowReloadWarning":false,"newBrowserWindowShowURL":2,"openDownloads":false,"oskBehavior":2,"permittedProcesses":[],"pinEmbeddedCertificates":false,"prohibitedProcesses":[],"proxies":{"org_safeexambrowser_SEB_AutoConfigurationEnabled":false,"org_safeexambrowser_SEB_AutoConfigurationJavaScript":"","org_safeexambrowser_SEB_AutoConfigurationURL":"","org_safeexambrowser_SEB_AutoDiscoveryEnabled":false,"org_safeexambrowser_SEB_ExceptionsList":[],"org_safeexambrowser_SEB_ExcludeSimpleHostnames":false,"org_safeexambrowser_SEB_FTPEnable":false,"org_safeexambrowser_SEB_FTPPassive":true,"org_safeexambrowser_SEB_FTPPassword":"","org_safeexambrowser_SEB_FTPPort":21,"org_safeexambrowser_SEB_FTPProxy":"","org_safeexambrowser_SEB_FTPRequiresPassword":false,"org_safeexambrowser_SEB_FTPUsername":"","org_safeexambrowser_SEB_HTTPEnable":false,"org_safeexambrowser_SEB_HTTPPassword":"","org_safeexambrowser_SEB_HTTPPort":80,"org_safeexambrowser_SEB_HTTPProxy":"","org_safeexambrowser_SEB_HTTPRequiresPassword":false,"org_safeexambrowser_SEB_HTTPSEnable":false,"org_safeexambrowser_SEB_HTTPSPassword":"","org_safeexambrowser_SEB_HTTPSPort":443,"org_safeexambrowser_SEB_HTTPSProxy":"","org_safeexambrowser_SEB_HTTPSRequiresPassword":false,"org_safeexambrowser_SEB_HTTPSUsername":"","org_safeexambrowser_SEB_HTTPUsername":"","org_safeexambrowser_SEB_RTSPEnable":false,"org_safeexambrowser_SEB_RTSPPassword":"","org_safeexambrowser_SEB_RTSPPort":554,"org_safeexambrowser_SEB_RTSPProxy":"","org_safeexambrowser_SEB_RTSPRequiresPassword":false,"org_safeexambrowser_SEB_RTSPUsername":"","org_safeexambrowser_SEB_SOCKSEnable":false,"org_safeexambrowser_SEB_SOCKSPassword":"","org_safeexambrowser_SEB_SOCKSPort":1080,"org_safeexambrowser_SEB_SOCKSProxy":"","org_safeexambrowser_SEB_SOCKSRequiresPassword":false,"org_safeexambrowser_SEB_SOCKSUsername":""},"proxySettingsPolicy":0,"quitURL":"","quitURLConfirm":true,"quitURLRestart":false,"removeBrowserProfile":false,"removeLocalStorage":false,"restartExamPasswordProtected":true,"restartExamText":"","restartExamURL":"","restartExamUseStartURL":false,"sebConfigPurpose":0,"sebMode":0,"sebServerFallback":false,"sebServerURL":"","sebServicePolicy":2,"sendBrowserExamKey":true,"showBackToStartButton":true,"showInputLanguage":false,"showMenuBar":true,"showNavigationButtons":false,"showQuitButton":true,"showReloadButton":true,"showReloadWarning":false,"showScanQRCodeButton":false,"showTaskBar":true,"showTime":true,"startURL":"https://www.google.com","startURLAllowDeepLink":false,"startURLAppendQueryParameter":false,"taskBarHeight":40,"touchOptimized":false,"URLFilterEnable":false,"URLFilterEnableContentFilter":false,"URLFilterIgnoreList":[],"URLFilterMessage":0,"urlFilterRegex":true,"URLFilterRules":[],"urlFilterTrustedContent":false,"whitelistURLFilter":"","zoomMode":0}PK9B\ x{3accessrule/seb/tests/fixtures/simpleunencrypted.sebnu[ aaaaaa 1940-10-09T22:13:56Z originatorVersion SEB_Win_2.1.1 startURL https://safeexambrowser.org/start startResource sebServerURL hashedAdminPassword allowQuit ignoreExitKeys allowUserAppFolderInstall allowSiri allowDictation detectStoppedProcess allowDisplayMirroring allowedDisplaysMaxNumber 1 allowedDisplayBuiltin PK9B\ؙp17175accessrule/seb/tests/fixtures/unencrypted_mac_001.sebnu[ URLFilterEnable URLFilterEnableContentFilter URLFilterIgnoreList URLFilterMessage 0 URLFilterRules additionalResources allowBrowsingBackForward allowDictation allowDictionaryLookup allowDisplayMirroring allowDownUploads allowFlashFullscreen allowPDFPlugIn allowPreferencesWindow allowQuit allowScreenSharing allowSiri allowSpellCheck allowSwitchToApplications allowUserAppFolderInstall allowUserSwitching allowVideoCapture allowVirtualMachine allowWlan allowedDisplayBuiltin allowedDisplaysMaxNumber 1 allowiOSBetaVersionNumber 0 allowiOSVersionNumberMajor 9 allowiOSVersionNumberMinor 3 allowiOSVersionNumberPatch 5 blacklistURLFilter blockPopUpWindows browserMediaAutoplay browserMessagingPingTime 120000 browserMessagingSocket ws:\localhost:8706 browserScreenKeyboard browserURLSalt browserUserAgent browserUserAgentMac 0 browserUserAgentMacCustom browserUserAgentWinDesktopMode 0 browserUserAgentWinDesktopModeCustom browserUserAgentWinTouchMode 0 browserUserAgentWinTouchModeCustom browserUserAgentWinTouchModeIPad Mozilla/5.0 (iPad; CPU OS 11_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/11.0 Mobile/15E148 Safari/604.1 browserUserAgentiOS 0 browserUserAgentiOSCustom browserViewMode 0 browserWindowAllowReload browserWindowShowURL 0 chooseFileToUploadPolicy 0 configFileCreateIdentity configFileEncryptUsingIdentity configKeySalt createNewDesktop detectStoppedProcess downloadAndOpenSebConfig downloadDirectoryOSX /Users/andrewmadden/Downloads downloadDirectoryWin Downloads downloadPDFFiles embeddedCertificates enableAltEsc enableAltF4 enableAltMouseWheel enableAltTab enableAppSwitcherCheck enableBrowserWindowToolbar enableCtrlEsc enableDrawingEditor enableEsc enableF1 enableF10 enableF11 enableF12 enableF2 enableF3 enableF4 enableF5 enableF6 enableF7 enableF8 enableF9 enableJava enableJavaScript enableLogging enablePlugIns enablePrintScreen enablePrivateClipboard enableRightMouse enableSebBrowser enableStartMenu enableTouchExit enableZoomPage enableZoomText examKeySalt 4iLONVAc1yKWLxbe6Vbf2vWYQO6zIRAobKgc3f25LH8= examSessionClearSessionCookies examSessionReconfigureAllow examSessionReconfigureConfigURL exitKey1 2 exitKey2 10 exitKey3 5 forceAppFolderInstall hashedAdminPassword hashedQuitPassword hideBrowserWindowToolbar hookKeys ignoreExitKeys ignoreQuitPassword insideSebEnableChangeAPassword insideSebEnableEaseOfAccess insideSebEnableLockThisComputer insideSebEnableLogOff insideSebEnableNetworkConnectionSelector insideSebEnableShutDown insideSebEnableStartTaskManager insideSebEnableSwitchUser insideSebEnableVmWareClientShade killExplorerShell logDirectoryOSX logDirectoryWin logLevel 4 logSendingRequiresAdminPassword mainBrowserWindowHeight 100% mainBrowserWindowPositioning 1 mainBrowserWindowWidth 100% minMacOSVersion 4 mobileAllowInlineMediaPlayback mobileAllowPictureInPictureMediaPlayback mobileAllowQRCodeConfig mobileAllowSingleAppMode mobileCompactAllowInlineMediaPlayback mobileEnableASAM mobileEnableGuidedAccessLinkTransform mobilePreventAutoLock mobileShowSettings mobileStatusBarAppearance 1 mobileStatusBarAppearanceExtended 1 mobileSupportedFormFactorsCompact mobileSupportedFormFactorsNonTelephonyCompact mobileSupportedFormFactorsRegular mobileSupportedScreenOrientationsCompactLandscapeLeft mobileSupportedScreenOrientationsCompactLandscapeRight mobileSupportedScreenOrientationsCompactPortrait mobileSupportedScreenOrientationsCompactPortraitUpsideDown mobileSupportedScreenOrientationsRegularLandscapeLeft mobileSupportedScreenOrientationsRegularLandscapeRight mobileSupportedScreenOrientationsRegularPortrait mobileSupportedScreenOrientationsRegularPortraitUpsideDown monitorProcesses newBrowserWindowAllowReload newBrowserWindowByLinkBlockForeign newBrowserWindowByLinkHeight 100% newBrowserWindowByLinkPolicy 1 newBrowserWindowByLinkPositioning 2 newBrowserWindowByLinkWidth 1000 newBrowserWindowByScriptBlockForeign newBrowserWindowByScriptPolicy 2 newBrowserWindowNavigation newBrowserWindowShowReloadWarning newBrowserWindowShowURL 2 openDownloads originatorVersion SEB_OSX_2.1.4_2B66 oskBehavior 2 permittedProcesses pinEmbeddedCertificates prohibitedProcesses proxies org_safeexambrowser_SEB_AutoConfigurationEnabled org_safeexambrowser_SEB_AutoConfigurationJavaScript org_safeexambrowser_SEB_AutoConfigurationURL org_safeexambrowser_SEB_AutoDiscoveryEnabled org_safeexambrowser_SEB_ExceptionsList org_safeexambrowser_SEB_ExcludeSimpleHostnames org_safeexambrowser_SEB_FTPEnable org_safeexambrowser_SEB_FTPPassive org_safeexambrowser_SEB_FTPPassword org_safeexambrowser_SEB_FTPPort 21 org_safeexambrowser_SEB_FTPProxy org_safeexambrowser_SEB_FTPRequiresPassword org_safeexambrowser_SEB_FTPUsername org_safeexambrowser_SEB_HTTPEnable org_safeexambrowser_SEB_HTTPPassword org_safeexambrowser_SEB_HTTPPort 80 org_safeexambrowser_SEB_HTTPProxy org_safeexambrowser_SEB_HTTPRequiresPassword org_safeexambrowser_SEB_HTTPSEnable org_safeexambrowser_SEB_HTTPSPassword org_safeexambrowser_SEB_HTTPSPort 443 org_safeexambrowser_SEB_HTTPSProxy org_safeexambrowser_SEB_HTTPSRequiresPassword org_safeexambrowser_SEB_HTTPSUsername org_safeexambrowser_SEB_HTTPUsername org_safeexambrowser_SEB_RTSPEnable org_safeexambrowser_SEB_RTSPPassword org_safeexambrowser_SEB_RTSPPort 554 org_safeexambrowser_SEB_RTSPProxy org_safeexambrowser_SEB_RTSPRequiresPassword org_safeexambrowser_SEB_RTSPUsername org_safeexambrowser_SEB_SOCKSEnable org_safeexambrowser_SEB_SOCKSPassword org_safeexambrowser_SEB_SOCKSPort 1080 org_safeexambrowser_SEB_SOCKSProxy org_safeexambrowser_SEB_SOCKSRequiresPassword org_safeexambrowser_SEB_SOCKSUsername proxySettingsPolicy 0 quitURL quitURLConfirm quitURLRestart removeBrowserProfile removeLocalStorage restartExamPasswordProtected restartExamText restartExamURL restartExamUseStartURL sebConfigPurpose 0 sebMode 0 sebServerFallback sebServerURL sebServicePolicy 2 sendBrowserExamKey showBackToStartButton showInputLanguage showMenuBar showNavigationButtons showQuitButton showReloadButton showReloadWarning showScanQRCodeButton showTaskBar showTime startURL https://www.google.com startURLAllowDeepLink startURLAppendQueryParameter taskBarHeight 40 touchOptimized urlFilterRegex urlFilterTrustedContent whitelistURLFilter zoomMode 0 PK9B\צ:= = +accessrule/seb/tests/fixtures/encrypted.sebnu[& pswds{ZQN_b}e%삛Mk1P>iN$~RY;₎IU, )n.LgMM dB>p4t:Ec&odO]7 l+>%FK !T+YވtJΥ]#xI|^_u11)'8 p[Uef*qg@!/VhG7q76%:3gD`8u>HL Ղ~%߄R1Yx]9e$YV|Rh怰3$rOccN\mtּ\[` S8];!yX2?PS!TqN5ꀳ<]tGG08}P^Gm4 yuR.F Yo"ejM\,S" voNeAMCNYfkN~˕;,M /NˀNZ@]g-ED:ى?_xȓ_'#槭yxgvqɅ7'}4;5uu7Ү&t>rEٓ dTXFO($(&ϓ1ZhxꓹpeAM.*w7}ًTDEet*D4pY.A Wj]1x@,,O(C[ "JQmm$1ͦ9tT) F)fF!Py%MQ>qM$UהR;@O+K'Eu 7~3sVb# B6@N) K#Y2~zs5TT j( M{x&R|XbzyŊ Q)(u o0;p{ԖyU~k{ BТG{dDR^ejŰT*4X\e#a5{mu \GuI 驉R:Ss0wRaٙ6ufzbhfp ~K&xf;7UY @Z ˫FS Dc% originatorVersion SEB_Win_2.1.1 startURL https://safeexambrowser.org/start startResource sebServerURL hashedAdminPassword allowQuit ignoreExitKeys hashedQuitPassword exitKey1 2 exitKey2 10 exitKey3 5 sebMode 0 browserMessagingSocket ws://localhost:8706 browserMessagingPingTime 120000 sebConfigPurpose 0 allowPreferencesWindow useAsymmetricOnlyEncryption browserViewMode 0 mainBrowserWindowWidth 100% mainBrowserWindowHeight 100% mainBrowserWindowPositioning 1 enableBrowserWindowToolbar hideBrowserWindowToolbar showMenuBar showTaskBar taskBarHeight 40 touchOptimized enableZoomText enableZoomPage zoomMode 0 allowSpellCheck allowDictionaryLookup allowSpellCheckDictionary additionalDictionaries showReloadButton showTime showInputLanguage enableTouchExit oskBehavior 2 audioControlEnabled audioMute audioVolumeLevel 25 audioSetVolumeLevel browserScreenKeyboard newBrowserWindowByLinkPolicy 2 newBrowserWindowByScriptPolicy 2 newBrowserWindowByLinkBlockForeign newBrowserWindowByScriptBlockForeign newBrowserWindowByLinkWidth 100% newBrowserWindowByLinkHeight 100% newBrowserWindowByLinkPositioning 2 enablePlugIns enableJava enableJavaScript blockPopUpWindows allowVideoCapture allowAudioCapture allowBrowsingBackForward newBrowserWindowNavigation removeBrowserProfile removeLocalStorage enableSebBrowser browserWindowAllowReload newBrowserWindowAllowReload showReloadWarning newBrowserWindowShowReloadWarning browserUserAgentWinDesktopMode 0 browserUserAgentWinDesktopModeCustom browserUserAgentWinTouchMode 0 browserUserAgentWinTouchModeIPad Mozilla/5.0 (iPad; CPU OS 11_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/11.3 Mobile/15E216 Safari/605.1.15 browserUserAgentWinTouchModeCustom browserUserAgent browserUserAgentMac 0 browserUserAgentMacCustom browserWindowTitleSuffix allowDownUploads downloadDirectoryOSX ~/Downloads downloadDirectoryWin openDownloads chooseFileToUploadPolicy 0 downloadPDFFiles allowPDFPlugIn downloadAndOpenSebConfig backgroundOpenSEBConfig examKeySalt 0zVdSEqKAnK0ZNd4QFlwVKBTgw60o6xRjpfDokUYYnk= browserExamKey browserURLSalt sendBrowserExamKey quitURL quitURLConfirm restartExamURL restartExamUseStartURL restartExamText restartExamPasswordProtected additionalResources monitorProcesses allowSwitchToApplications allowFlashFullscreen permittedProcesses active autostart iconInTaskbar runInBackground allowUserToChooseApp strongKill os 1 title SEB description executable firefox.exe originalName firefox.exe path ../xulrunner/ identifier Firefox windowHandlingProcess arguments prohibitedProcesses active currentUser strongKill os 1 executable join.me originalName join.me description identifier windowHandlingProcess user active currentUser strongKill os 1 executable RPCSuite originalName RPCSuite description identifier windowHandlingProcess user active currentUser strongKill os 1 executable RPCService originalName RPCService description identifier windowHandlingProcess user active currentUser strongKill os 1 executable RemotePCDesktop originalName RemotePCDesktop description identifier windowHandlingProcess user active currentUser strongKill os 1 executable beamyourscreen-host originalName beamyourscreen-host description identifier windowHandlingProcess user active currentUser strongKill os 1 executable AeroAdmin originalName AeroAdmin description identifier windowHandlingProcess user active currentUser strongKill os 1 executable Mikogo-host originalName Mikogo-host description identifier windowHandlingProcess user active currentUser strongKill os 1 executable chromoting originalName chromoting description identifier windowHandlingProcess user active currentUser strongKill os 1 executable vncserverui originalName vncserverui description identifier windowHandlingProcess user active currentUser strongKill os 1 executable vncviewer originalName vncviewer description identifier windowHandlingProcess user active currentUser strongKill os 1 executable vncserver originalName vncserver description identifier windowHandlingProcess user active currentUser strongKill os 1 executable TeamViewer originalName TeamViewer description identifier windowHandlingProcess user active currentUser strongKill os 1 executable GotoMeetingWinStore originalName GotoMeetingWinStore description identifier windowHandlingProcess user active currentUser strongKill os 1 executable g2mcomm.exe originalName g2mcomm.exe description identifier windowHandlingProcess user active currentUser strongKill os 1 executable SkypeHost originalName SkypeHost description identifier windowHandlingProcess user active currentUser strongKill os 1 executable Skype originalName Skype description identifier windowHandlingProcess user enableURLFilter enableURLContentFilter URLFilterRules URLFilterEnable URLFilterEnableContentFilter blacklistURLFilter whitelistURLFilter urlFilterTrustedContent urlFilterRegex embeddedCertificates pinEmbeddedCertificates proxySettingsPolicy 0 proxies ExceptionsList ExcludeSimpleHostnames AutoDiscoveryEnabled AutoConfigurationEnabled AutoConfigurationJavaScript AutoConfigurationURL FTPPassive HTTPEnable HTTPPort 80 HTTPProxy HTTPRequiresPassword HTTPUsername HTTPPassword HTTPSEnable HTTPSPort 443 HTTPSProxy HTTPSRequiresPassword HTTPSUsername HTTPSPassword FTPEnable FTPPort 21 FTPProxy FTPRequiresPassword FTPUsername FTPPassword SOCKSEnable SOCKSPort 1080 SOCKSProxy SOCKSRequiresPassword SOCKSUsername SOCKSPassword RTSPEnable RTSPPort 554 RTSPProxy RTSPRequiresPassword RTSPUsername RTSPPassword sebServicePolicy 1 allowVirtualMachine allowScreenSharing enablePrivateClipboard createNewDesktop killExplorerShell enableLogging logDirectoryOSX ~/Documents logDirectoryWin allowWlan lockOnMessageSocketClose minMacOSVersion 4 enableAppSwitcherCheck forceAppFolderInstall allowUserAppFolderInstall allowSiri allowDictation detectStoppedProcess allowDisplayMirroring allowedDisplaysMaxNumber 1 allowedDisplayBuiltin insideSebEnableSwitchUser insideSebEnableLockThisComputer insideSebEnableChangeAPassword insideSebEnableStartTaskManager insideSebEnableLogOff insideSebEnableShutDown insideSebEnableEaseOfAccess insideSebEnableVmWareClientShade insideSebEnableEnableNetworkConnectionSelector hookKeys enableEsc enableCtrlEsc enableAltEsc enableAltTab enableAltF4 enableStartMenu enableRightMouse enablePrintScreen enableAltMouseWheel enableF1 enableF2 enableF3 enableF4 enableF5 enableF6 enableF7 enableF8 enableF9 enableF10 enableF11 enableF12 PK9B\1!P!P5accessrule/seb/tests/fixtures/unencrypted_win_223.sebnu[ batteryChargeThresholdCritical 0.1 batteryChargeThresholdLow 0.2 originatorVersion SEB_Win_2.1.1 startURL https://safeexambrowser.org/start startResource sebServerURL hashedAdminPassword allowQuit ignoreExitKeys hashedQuitPassword exitKey1 2 exitKey2 10 exitKey3 5 sebMode 0 browserMessagingSocket ws://localhost:8706 browserMessagingPingTime 120000 sebConfigPurpose 0 allowPreferencesWindow useAsymmetricOnlyEncryption browserViewMode 0 mainBrowserWindowWidth 100% mainBrowserWindowHeight 100% mainBrowserWindowPositioning 1 enableBrowserWindowToolbar hideBrowserWindowToolbar showMenuBar showTaskBar taskBarHeight 40 touchOptimized enableZoomText enableZoomPage zoomMode 0 allowSpellCheck allowDictionaryLookup allowSpellCheckDictionary additionalDictionaries showReloadButton showTime showInputLanguage enableTouchExit oskBehavior 2 audioControlEnabled audioMute audioVolumeLevel 25 audioSetVolumeLevel browserScreenKeyboard newBrowserWindowByLinkPolicy 2 newBrowserWindowByScriptPolicy 2 newBrowserWindowByLinkBlockForeign newBrowserWindowByScriptBlockForeign newBrowserWindowByLinkWidth 1000 newBrowserWindowByLinkHeight 100% newBrowserWindowByLinkPositioning 2 enablePlugIns enableJava enableJavaScript blockPopUpWindows allowVideoCapture allowAudioCapture allowBrowsingBackForward newBrowserWindowNavigation removeBrowserProfile removeLocalStorage enableSebBrowser browserWindowAllowReload newBrowserWindowAllowReload showReloadWarning newBrowserWindowShowReloadWarning browserUserAgentWinDesktopMode 0 browserUserAgentWinDesktopModeCustom browserUserAgentWinTouchMode 0 browserUserAgentWinTouchModeIPad Mozilla/5.0 (iPad; CPU OS 11_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/11.3 Mobile/15E216 Safari/605.1.15 browserUserAgentWinTouchModeCustom browserUserAgent browserUserAgentMac 0 browserUserAgentMacCustom browserWindowTitleSuffix allowDownUploads downloadDirectoryOSX ~/Downloads downloadDirectoryWin openDownloads chooseFileToUploadPolicy 0 downloadPDFFiles allowPDFPlugIn downloadAndOpenSebConfig backgroundOpenSEBConfig examKeySalt QJAqvg89YMP6JagAshUm6QqpqpsrVS9ZWUYjdZhfEao= browserExamKey browserURLSalt sendBrowserExamKey quitURL quitURLConfirm restartExamURL restartExamUseStartURL restartExamText restartExamPasswordProtected additionalResources monitorProcesses allowSwitchToApplications allowFlashFullscreen permittedProcesses active autostart iconInTaskbar runInBackground allowUserToChooseApp strongKill os 1 title SEB description executable firefox.exe originalName firefox.exe path ../xulrunner/ identifier Firefox windowHandlingProcess arguments prohibitedProcesses active currentUser strongKill os 1 executable join.me originalName join.me description identifier windowHandlingProcess user active currentUser strongKill os 1 executable RPCSuite originalName RPCSuite description identifier windowHandlingProcess user active currentUser strongKill os 1 executable RPCService originalName RPCService description identifier windowHandlingProcess user active currentUser strongKill os 1 executable RemotePCDesktop originalName RemotePCDesktop description identifier windowHandlingProcess user active currentUser strongKill os 1 executable beamyourscreen-host originalName beamyourscreen-host description identifier windowHandlingProcess user active currentUser strongKill os 1 executable AeroAdmin originalName AeroAdmin description identifier windowHandlingProcess user active currentUser strongKill os 1 executable Mikogo-host originalName Mikogo-host description identifier windowHandlingProcess user active currentUser strongKill os 1 executable chromoting originalName chromoting description identifier windowHandlingProcess user active currentUser strongKill os 1 executable vncserverui originalName vncserverui description identifier windowHandlingProcess user active currentUser strongKill os 1 executable vncviewer originalName vncviewer description identifier windowHandlingProcess user active currentUser strongKill os 1 executable vncserver originalName vncserver description identifier windowHandlingProcess user active currentUser strongKill os 1 executable TeamViewer originalName TeamViewer description identifier windowHandlingProcess user active currentUser strongKill os 1 executable GotoMeetingWinStore originalName GotoMeetingWinStore description identifier windowHandlingProcess user active currentUser strongKill os 1 executable g2mcomm.exe originalName g2mcomm.exe description identifier windowHandlingProcess user active currentUser strongKill os 1 executable SkypeHost originalName SkypeHost description identifier windowHandlingProcess user active currentUser strongKill os 1 executable Skype originalName Skype description identifier windowHandlingProcess user enableURLFilter enableURLContentFilter URLFilterRules URLFilterEnable URLFilterEnableContentFilter blacklistURLFilter whitelistURLFilter urlFilterTrustedContent urlFilterRegex embeddedCertificates pinEmbeddedCertificates proxySettingsPolicy 0 proxies ExceptionsList ExcludeSimpleHostnames AutoDiscoveryEnabled AutoConfigurationEnabled AutoConfigurationJavaScript AutoConfigurationURL FTPPassive HTTPEnable HTTPPort 80 HTTPProxy HTTPRequiresPassword HTTPUsername HTTPPassword HTTPSEnable HTTPSPort 443 HTTPSProxy HTTPSRequiresPassword HTTPSUsername HTTPSPassword FTPEnable FTPPort 21 FTPProxy FTPRequiresPassword FTPUsername FTPPassword SOCKSEnable SOCKSPort 1080 SOCKSProxy SOCKSRequiresPassword SOCKSUsername SOCKSPassword RTSPEnable RTSPPort 554 RTSPProxy RTSPRequiresPassword RTSPUsername RTSPPassword sebServicePolicy 1 allowVirtualMachine allowScreenSharing enablePrivateClipboard createNewDesktop killExplorerShell enableLogging logDirectoryOSX ~/Documents logDirectoryWin allowWlan lockOnMessageSocketClose minMacOSVersion 4 enableAppSwitcherCheck forceAppFolderInstall allowUserAppFolderInstall allowSiri allowDictation detectStoppedProcess allowDisplayMirroring allowedDisplaysMaxNumber 1 allowedDisplayBuiltin insideSebEnableSwitchUser insideSebEnableLockThisComputer insideSebEnableChangeAPassword insideSebEnableStartTaskManager insideSebEnableLogOff insideSebEnableShutDown insideSebEnableEaseOfAccess insideSebEnableVmWareClientShade insideSebEnableEnableNetworkConnectionSelector hookKeys enableEsc enableCtrlEsc enableAltEsc enableAltTab enableAltF4 enableStartMenu enableRightMouse enablePrintScreen enableAltMouseWheel enableF1 enableF2 enableF3 enableF4 enableF5 enableF6 enableF7 enableF8 enableF9 enableF10 enableF11 enableF12 PK9B\75Daccessrule/seb/tests/fixtures/simpleunencryptedwithoutoriginator.sebnu[ aaaaaa 1940-10-09T22:13:56Z startURL https://safeexambrowser.org/start startResource sebServerURL hashedAdminPassword allowQuit ignoreExitKeys allowUserAppFolderInstall allowSiri allowDictation detectStoppedProcess allowDisplayMirroring allowedDisplaysMaxNumber 1 allowedDisplayBuiltin PK9B\~22,accessrule/seb/tests/backup_restore_test.phpnu[. namespace quizaccess_seb; defined('MOODLE_INTERNAL') || die(); require_once(__DIR__ . '/test_helper_trait.php'); /** * PHPUnit tests for backup and restore functionality. * * @package quizaccess_seb * @author Dmitrii Metelkin * @copyright 2020 Catalyst IT * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class backup_restore_test extends \advanced_testcase { use \quizaccess_seb_test_helper_trait; /** @var template $template A test template. */ protected $template; /** * Called before every test. */ public function setUp(): void { global $USER; parent::setUp(); $this->resetAfterTest(); $this->setAdminUser(); $this->course = $this->getDataGenerator()->create_course(); $this->template = $this->create_template(); $this->user = $USER; } /** * A helper method to create a quiz with template usage of SEB. * * @return seb_quiz_settings */ protected function create_quiz_with_template() { $this->quiz = $this->create_test_quiz($this->course, settings_provider::USE_SEB_CONFIG_MANUALLY); $quizsettings = seb_quiz_settings::get_record(['quizid' => $this->quiz->id]); $quizsettings->set('requiresafeexambrowser', settings_provider::USE_SEB_TEMPLATE); $quizsettings->set('templateid', $this->template->get('id')); $quizsettings->save(); return $quizsettings; } /** * A helper method to emulate backup and restore of the quiz. * * @return \cm_info|null */ protected function backup_and_restore_quiz() { return duplicate_module($this->course, get_fast_modinfo($this->course)->get_cm($this->quiz->cmid)); } /** * A helper method to backup test quiz. * * @return mixed A backup ID ready to be restored. */ protected function backup_quiz() { global $CFG; // Get the necessary files to perform backup and restore. require_once($CFG->dirroot . '/backup/util/includes/backup_includes.php'); require_once($CFG->dirroot . '/backup/util/includes/restore_includes.php'); $backupid = 'test-seb-backup-restore'; $bc = new \backup_controller(\backup::TYPE_1ACTIVITY, $this->quiz->coursemodule, \backup::FORMAT_MOODLE, \backup::INTERACTIVE_NO, \backup::MODE_GENERAL, $this->user->id); $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(); return $backupid; } /** * A helper method to restore provided backup. * * @param string $backupid Backup ID to restore. */ protected function restore_quiz($backupid) { $rc = new \restore_controller($backupid, $this->course->id, \backup::INTERACTIVE_NO, \backup::MODE_GENERAL, $this->user->id, \backup::TARGET_CURRENT_ADDING); $this->assertTrue($rc->execute_precheck()); $rc->execute_plan(); $rc->destroy(); } /** * A helper method to emulate restoring to a different site. */ protected function change_site() { set_config('siteidentifier', random_string(32) . 'not the same site'); } /** * A helper method to validate backup and restore results. * * @param cm_info $newcm Restored course_module object. */ protected function validate_backup_restore(\cm_info $newcm) { $this->assertEquals(2, seb_quiz_settings::count_records()); $actual = seb_quiz_settings::get_record(['quizid' => $newcm->instance]); $expected = seb_quiz_settings::get_record(['quizid' => $this->quiz->id]); $this->assertEquals($expected->get('templateid'), $actual->get('templateid')); $this->assertEquals($expected->get('requiresafeexambrowser'), $actual->get('requiresafeexambrowser')); $this->assertEquals($expected->get('showsebdownloadlink'), $actual->get('showsebdownloadlink')); $this->assertEquals($expected->get('allowuserquitseb'), $actual->get('allowuserquitseb')); $this->assertEquals($expected->get('quitpassword'), $actual->get('quitpassword')); $this->assertEquals($expected->get('allowedbrowserexamkeys'), $actual->get('allowedbrowserexamkeys')); // Validate specific SEB config settings. foreach (settings_provider::get_seb_config_elements() as $name => $notused) { $name = preg_replace("/^seb_/", "", $name); $this->assertEquals($expected->get($name), $actual->get($name)); } } /** * Test backup and restore when no seb. */ public function test_backup_restore_no_seb(): void { $this->quiz = $this->create_test_quiz($this->course, settings_provider::USE_SEB_NO); $this->assertEquals(0, seb_quiz_settings::count_records()); $this->backup_and_restore_quiz(); $this->assertEquals(0, seb_quiz_settings::count_records()); } /** * Test backup and restore when manually configured. */ public function test_backup_restore_manual_config(): void { $this->quiz = $this->create_test_quiz($this->course, settings_provider::USE_SEB_CONFIG_MANUALLY); $expected = seb_quiz_settings::get_record(['quizid' => $this->quiz->id]); $expected->set('showsebdownloadlink', 0); $expected->set('quitpassword', '123'); $expected->save(); $this->assertEquals(1, seb_quiz_settings::count_records()); $newcm = $this->backup_and_restore_quiz(); $this->validate_backup_restore($newcm); } /** * Test backup and restore when using template. */ public function test_backup_restore_template_config(): void { $this->quiz = $this->create_test_quiz($this->course, settings_provider::USE_SEB_CONFIG_MANUALLY); $expected = seb_quiz_settings::get_record(['quizid' => $this->quiz->id]); $template = $this->create_template(); $expected->set('requiresafeexambrowser', settings_provider::USE_SEB_TEMPLATE); $expected->set('templateid', $template->get('id')); $expected->save(); $this->assertEquals(1, seb_quiz_settings::count_records()); $newcm = $this->backup_and_restore_quiz(); $this->validate_backup_restore($newcm); } /** * Test backup and restore when using template when said template is disabled. * * @covers \quizaccess_seb\seb_quiz_settings::get_record * @covers \restore_quizaccess_seb_subplugin::process_quizaccess_seb_quizsettings */ public function test_backup_restore_disabled_template_config(): void { $this->quiz = $this->create_test_quiz($this->course, settings_provider::USE_SEB_CONFIG_MANUALLY); $expected = seb_quiz_settings::get_record(['quizid' => $this->quiz->id]); $template = $this->create_template(); $expected->set('requiresafeexambrowser', settings_provider::USE_SEB_TEMPLATE); $expected->set('templateid', $template->get('id')); $expected->save(); // Disable template. $template->set('enabled', 0); $template->save(); $this->assertEquals(1, seb_quiz_settings::count_records()); $newcm = $this->backup_and_restore_quiz(); $this->assertEquals(2, seb_quiz_settings::count_records()); $actual = seb_quiz_settings::get_record(['quizid' => $newcm->instance]); // Test that the restored quiz no longer uses SEB. $expected = seb_quiz_settings::get_record(['quizid' => $this->quiz->id]); $this->assertEquals(0, $actual->get('templateid')); $this->assertEquals(settings_provider::USE_SEB_NO, $actual->get('requiresafeexambrowser')); $this->assertEquals($expected->get('showsebdownloadlink'), $actual->get('showsebdownloadlink')); $this->assertEquals($expected->get('allowuserquitseb'), $actual->get('allowuserquitseb')); $this->assertEquals($expected->get('quitpassword'), $actual->get('quitpassword')); $this->assertEquals($expected->get('allowedbrowserexamkeys'), $actual->get('allowedbrowserexamkeys')); // Validate specific SEB config settings. foreach (settings_provider::get_seb_config_elements() as $name => $notused) { $name = preg_replace("/^seb_/", "", $name); $this->assertEquals($expected->get($name), $actual->get($name)); } } /** * Test backup and restore when using uploaded file. */ public function test_backup_restore_uploaded_config(): void { $this->quiz = $this->create_test_quiz($this->course, settings_provider::USE_SEB_CONFIG_MANUALLY); $expected = seb_quiz_settings::get_record(['quizid' => $this->quiz->id]); $expected->set('requiresafeexambrowser', settings_provider::USE_SEB_UPLOAD_CONFIG); $xml = file_get_contents(self::get_fixture_path(__NAMESPACE__, 'unencrypted.seb')); $this->create_module_test_file($xml, $this->quiz->cmid); $expected->save(); $this->assertEquals(1, seb_quiz_settings::count_records()); $newcm = $this->backup_and_restore_quiz(); $this->validate_backup_restore($newcm); $expectedfile = settings_provider::get_module_context_sebconfig_file($this->quiz->cmid); $actualfile = settings_provider::get_module_context_sebconfig_file($newcm->id); $this->assertEquals($expectedfile->get_content(), $actualfile->get_content()); } /** * No new template should be restored if restoring to a different site, * but the template with the same name and content exists.. */ public function test_restore_template_to_a_different_site_when_the_same_template_exists(): void { $this->create_quiz_with_template(); $backupid = $this->backup_quiz(); $this->assertEquals(1, seb_quiz_settings::count_records()); $this->assertEquals(1, template::count_records()); $this->change_site(); $this->restore_quiz($backupid); // Should see additional setting record, but no new template record. $this->assertEquals(2, seb_quiz_settings::count_records()); $this->assertEquals(1, template::count_records()); } /** * A new template should be restored if restoring to a different site, but existing template * has the same content, but different name. */ public function test_restore_template_to_a_different_site_when_the_same_content_but_different_name(): void { $this->create_quiz_with_template(); $backupid = $this->backup_quiz(); $this->assertEquals(1, seb_quiz_settings::count_records()); $this->assertEquals(1, template::count_records()); $this->template->set('name', 'New name for template'); $this->template->save(); $this->change_site(); $this->restore_quiz($backupid); // Should see additional setting record, and new template record. $this->assertEquals(2, seb_quiz_settings::count_records()); $this->assertEquals(2, template::count_records()); } /** * A new template should be restored if restoring to a different site, but existing template * has the same name, but different content. */ public function test_restore_template_to_a_different_site_when_the_same_name_but_different_content(): void { global $CFG; $this->create_quiz_with_template(); $backupid = $this->backup_quiz(); $this->assertEquals(1, seb_quiz_settings::count_records()); $this->assertEquals(1, template::count_records()); $newxml = file_get_contents($CFG->dirroot . '/mod/quiz/accessrule/seb/tests/fixtures/simpleunencrypted.seb'); $this->template->set('content', $newxml); $this->template->save(); $this->change_site(); $this->restore_quiz($backupid); // Should see additional setting record, and new template record. $this->assertEquals(2, seb_quiz_settings::count_records()); $this->assertEquals(2, template::count_records()); } } PK9B\y++"accessrule/seb/tests/rule_test.phpnu[. namespace quizaccess_seb; use quizaccess_seb; defined('MOODLE_INTERNAL') || die(); require_once(__DIR__ . '/test_helper_trait.php'); /** * PHPUnit tests for plugin rule class. * * @package quizaccess_seb * @author Andrew Madden * @copyright 2020 Catalyst IT * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @covers \quizaccess_seb */ final class rule_test extends \advanced_testcase { use \quizaccess_seb_test_helper_trait; /** * Called before every test. */ public function setUp(): void { parent::setUp(); $this->resetAfterTest(); $this->course = $this->getDataGenerator()->create_course(); } /** * Called after every test. */ public function tearDown(): void { global $SESSION; if (!empty($this->quiz)) { unset($SESSION->quizaccess_seb_access); } parent::tearDown(); } /** * Helper method to get SEB download link for testing. * * @return string */ private function get_seb_download_link() { return 'https://safeexambrowser.org/download_en.html'; } /** * Helper method to get SEB launch link for testing. * * @return string */ private function get_seb_launch_link() { return 'sebs://www.example.com/moodle/mod/quiz/accessrule/seb/config.php'; } /** * Helper method to get SEB config download link for testing. * * @return string */ private function get_seb_config_download_link() { return 'https://www.example.com/moodle/mod/quiz/accessrule/seb/config.php'; } /** * Provider to return valid form field data when saving settings. * * @return array */ public static function valid_form_data_provider(): array { return [ 'valid seb_requiresafeexambrowser' => ['seb_requiresafeexambrowser', '0'], 'valid seb_linkquitseb0' => ['seb_linkquitseb', 'http://safeexambrowser.org/macosx'], 'valid seb_linkquitseb1' => ['seb_linkquitseb', 'safeexambrowser.org/macosx'], 'valid seb_linkquitseb2' => ['seb_linkquitseb', 'www.safeexambrowser.org/macosx'], 'valid seb_linkquitseb3' => ['seb_linkquitseb', 'any.type.of.url.looking.thing'], 'valid seb_linkquitseb4' => ['seb_linkquitseb', 'http://any.type.of.url.looking.thing'], ]; } /** * Provider to return invalid form field data when saving settings. * * @return array */ public static function invalid_form_data_provider(): array { return [ 'invalid seb_requiresafeexambrowser' => ['seb_requiresafeexambrowser', 'Uh oh!'], 'invalid seb_linkquitseb0' => ['seb_linkquitseb', '\0'], 'invalid seb_linkquitseb1' => ['seb_linkquitseb', 'invalid url'], 'invalid seb_linkquitseb2' => ['seb_linkquitseb', 'http]://safeexambrowser.org/macosx'], 'invalid seb_linkquitseb3' => ['seb_linkquitseb', '0'], 'invalid seb_linkquitseb4' => ['seb_linkquitseb', 'seb://any.type.of.url.looking.thing'], ]; } /** * Test no errors are found with valid data. * * @param string $setting * @param string $data * * @dataProvider valid_form_data_provider */ public function test_validate_settings_with_valid_data(string $setting, string $data): void { $this->setAdminUser(); $this->quiz = $this->create_test_quiz($this->course, settings_provider::USE_SEB_CONFIG_MANUALLY); $form = $this->createMock('mod_quiz_mod_form'); $form->method('get_context')->willReturn(\context_module::instance($this->quiz->cmid)); // Validate settings with a dummy form. $errors = quizaccess_seb::validate_settings_form_fields([], [ 'instance' => $this->quiz->id, 'coursemodule' => $this->quiz->cmid, $setting => $data ], [], $form); $this->assertEmpty($errors); } /** * Test errors are found with invalid data. * * @param string $setting * @param string $data * * @dataProvider invalid_form_data_provider */ public function test_validate_settings_with_invalid_data(string $setting, string $data): void { $this->setAdminUser(); $this->quiz = $this->create_test_quiz($this->course, settings_provider::USE_SEB_CONFIG_MANUALLY); $form = $this->createMock('mod_quiz_mod_form'); $form->method('get_context')->willReturn(\context_module::instance($this->quiz->cmid)); // Validate settings with a dummy form and quiz instance. $errors = quizaccess_seb::validate_settings_form_fields([], [ 'instance' => $this->quiz->id, 'coursemodule' => $this->quiz->cmid, $setting => $data ], [], $form); $this->assertEquals([$setting => 'Data submitted is invalid'], $errors); } /** * Test settings validation is not run if settings are locked. */ public function test_settings_validation_is_not_run_if_settings_are_locked(): void { $user = $this->getDataGenerator()->create_user(); $this->quiz = $this->create_test_quiz($this->course); $this->attempt_quiz($this->quiz, $user); $this->setAdminUser(); $form = $this->createMock('mod_quiz_mod_form'); $form->method('get_context')->willReturn(\context_module::instance($this->quiz->cmid)); // Validate settings with a dummy form and quiz instance. $errors = quizaccess_seb::validate_settings_form_fields([], [ 'instance' => $this->quiz->id, 'coursemodule' => $this->quiz->cmid, 'seb_requiresafeexambrowser' => 'Uh oh!' ], [], $form); $this->assertEmpty($errors); } /** * Test settings validation is not run if settings are conflicting. */ public function test_settings_validation_is_not_run_if_conflicting_permissions(): void { $this->setAdminUser(); $this->quiz = $this->create_test_quiz($this->course, settings_provider::USE_SEB_CONFIG_MANUALLY); $form = $this->createMock('mod_quiz_mod_form'); $form->method('get_context')->willReturn(\context_module::instance($this->quiz->cmid)); $user = $this->getDataGenerator()->create_user(); $roleid = $this->getDataGenerator()->create_role(); $context = \context_module::instance($this->quiz->cmid); assign_capability('quizaccess/seb:manage_seb_requiresafeexambrowser', CAP_ALLOW, $roleid, $context->id); $this->getDataGenerator()->role_assign($roleid, $user->id, $context->id); // By default The user won't have permissions to configure manually. $this->setUser($user); // Validate settings with a dummy form and quiz instance. $errors = quizaccess_seb::validate_settings_form_fields([], [ 'instance' => $this->quiz->id, 'coursemodule' => $this->quiz->cmid, 'seb_requiresafeexambrowser' => 'Uh oh!' ], [], $form); $this->assertEmpty($errors); } /** * Test bypassing validation if user don't have permissions to manage seb settings. */ public function test_validate_settings_is_not_run_if_a_user_do_not_have_permissions_to_manage_seb_settings(): void { // Set the user who can't change seb settings. So validation should be bypassed. $user = $this->getDataGenerator()->create_user(); $this->setUser($user); $this->quiz = $this->create_test_quiz($this->course, settings_provider::USE_SEB_CONFIG_MANUALLY); $form = $this->createMock('mod_quiz_mod_form'); $form->method('get_context')->willReturn(\context_module::instance($this->quiz->cmid)); // Validate settings with a dummy form and quiz instance. $errors = quizaccess_seb::validate_settings_form_fields([], [ 'instance' => $this->quiz->id, 'coursemodule' => $this->quiz->cmid, 'seb_requiresafeexambrowser' => 'Uh oh!' ], [], $form); $this->assertEmpty($errors); } /** * Test settings are saved to DB. */ public function test_create_settings_with_existing_quiz(): void { global $DB; $this->setAdminUser(); $this->quiz = $this->create_test_quiz($this->course, settings_provider::USE_SEB_NO); $this->assertFalse($DB->record_exists('quizaccess_seb_quizsettings', ['quizid' => $this->quiz->id])); $this->quiz->seb_requiresafeexambrowser = settings_provider::USE_SEB_CONFIG_MANUALLY; quizaccess_seb::save_settings($this->quiz); $this->assertNotFalse($DB->record_exists('quizaccess_seb_quizsettings', ['quizid' => $this->quiz->id])); } /** * Test settings are not saved to DB if settings are locked. */ public function test_settings_are_not_saved_if_settings_are_locked(): void { global $DB; $this->setAdminUser(); $this->quiz = $this->create_test_quiz($this->course); $user = $this->getDataGenerator()->create_user(); $this->setUser($user); $this->attempt_quiz($this->quiz, $user); $this->setAdminUser(); $this->quiz->seb_requiresafeexambrowser = settings_provider::USE_SEB_CONFIG_MANUALLY; quizaccess_seb::save_settings($this->quiz); $this->assertFalse($DB->record_exists('quizaccess_seb_quizsettings', ['quizid' => $this->quiz->id])); } /** * Test settings are not saved to DB if conflicting permissions. */ public function test_settings_are_not_saved_if_conflicting_permissions(): void { $this->setAdminUser(); $this->quiz = $this->create_test_quiz($this->course, settings_provider::USE_SEB_CONFIG_MANUALLY); $user = $this->getDataGenerator()->create_user(); $roleid = $this->getDataGenerator()->create_role(); $context = \context_module::instance($this->quiz->cmid); assign_capability('quizaccess/seb:manage_seb_requiresafeexambrowser', CAP_ALLOW, $roleid, $context->id); $this->getDataGenerator()->role_assign($roleid, $user->id, $context->id); // By default The user won't have permissions to configure manually. $this->setUser($user); $this->quiz->seb_requiresafeexambrowser = settings_provider::USE_SEB_NO; quizaccess_seb::save_settings($this->quiz); $quizsettings = seb_quiz_settings::get_record(['quizid' => $this->quiz->id]); $this->assertEquals(settings_provider::USE_SEB_CONFIG_MANUALLY, $quizsettings->get('requiresafeexambrowser')); } /** * Test exception thrown if cm could not be found while saving settings. */ public function test_save_settings_throw_an_exception_if_cm_not_found(): void { global $DB; $this->expectException(\dml_missing_record_exception::class); $this->expectExceptionMessage('Can\'t find data record in database.'); $this->setAdminUser(); $this->quiz = $this->create_test_quiz($this->course, settings_provider::USE_SEB_CONFIG_MANUALLY); $DB->delete_records('quiz', ['id' => $this->quiz->id]); $this->quiz->seb_requiresafeexambrowser = settings_provider::USE_SEB_NO; quizaccess_seb::save_settings($this->quiz); } /** * Test nothing happens when deleted is called without settings saved. */ public function test_delete_settings_without_existing_settings(): void { global $DB; $this->setAdminUser(); $quiz = new \stdClass(); $quiz->id = 1; quizaccess_seb::delete_settings($quiz); $this->assertFalse($DB->record_exists('quizaccess_seb_quizsettings', ['quizid' => $quiz->id])); } /** * Test settings are deleted from DB. */ public function test_delete_settings_with_existing_settings(): void { global $DB; $this->setAdminUser(); $this->quiz = $this->create_test_quiz($this->course, settings_provider::USE_SEB_CONFIG_MANUALLY); // Using a generator will create the quiz_settings record. $this->assertNotFalse($DB->record_exists('quizaccess_seb_quizsettings', ['quizid' => $this->quiz->id])); quizaccess_seb::delete_settings($this->quiz); $this->assertFalse($DB->record_exists('quizaccess_seb_quizsettings', ['quizid' => $this->quiz->id])); } /** * A helper method to check invalid config key. */ protected function check_invalid_config_key() { // Create an event sink, trigger event and retrieve event. $sink = $this->redirectEvents(); // Check that correct error message is returned. $errormsg = $this->make_rule()->prevent_access(); $this->assertNotEmpty($errormsg); $this->assertStringContainsString("The Safe Exam Browser keys could not be validated. " . "Check that you're using Safe Exam Browser with the correct configuration file.", $errormsg); $this->assertStringContainsString($this->get_seb_download_link(), $errormsg); $this->assertStringContainsString($this->get_seb_launch_link(), $errormsg); $this->assertStringContainsString($this->get_seb_config_download_link(), $errormsg); $events = $sink->get_events(); $this->assertEquals(1, count($events)); $event = reset($events); // Test that the event data is as expected. $this->assertInstanceOf('\quizaccess_seb\event\access_prevented', $event); $this->assertEquals('Invalid SEB config key', $event->other['reason']); } /** * Test access prevented if config key is invalid. */ public function test_access_prevented_if_config_key_invalid(): void { global $FULLME; $this->setAdminUser(); $this->quiz = $this->create_test_quiz($this->course, settings_provider::USE_SEB_CONFIG_MANUALLY); // Set up dummy request. $FULLME = 'https://example.com/moodle/mod/quiz/attempt.php?attemptid=123&page=4'; $_SERVER['HTTP_USER_AGENT'] = 'SEB'; $_SERVER['HTTP_X_SAFEEXAMBROWSER_CONFIGKEYHASH'] = 'Broken config key'; $user = $this->getDataGenerator()->create_user(); $this->setUser($user); $this->check_invalid_config_key(); } /** * Test access prevented if config key is invalid when not using the Safe Exam Browser. */ public function test_access_prevented_if_config_key_invalid_not_using_seb(): void { global $FULLME; $this->setAdminUser(); $this->quiz = $this->create_test_quiz($this->course, settings_provider::USE_SEB_CONFIG_MANUALLY); // Set up dummy request. $FULLME = 'https://example.com/moodle/mod/quiz/attempt.php?attemptid=123&page=4'; $_SERVER['HTTP_X_SAFEEXAMBROWSER_CONFIGKEYHASH'] = 'Broken config key'; $user = $this->getDataGenerator()->create_user(); $this->setUser($user); $errormsg = $this->make_rule()->prevent_access(); $this->assertNotEmpty($errormsg); $this->assertStringNotContainsString("The Safe Exam Browser keys could not be validated. " . "Check that you're using Safe Exam Browser with the correct configuration file.", $errormsg); } /** * Test access prevented if config keys is invalid and using uploaded config. */ public function test_access_prevented_if_config_key_invalid_uploaded_config(): void { global $FULLME; $this->setAdminUser(); $this->quiz = $this->create_test_quiz($this->course, settings_provider::USE_SEB_CONFIG_MANUALLY); // Set quiz setting to require seb and save BEK. $quizsettings = seb_quiz_settings::get_record(['quizid' => $this->quiz->id]); $quizsettings->set('requiresafeexambrowser', settings_provider::USE_SEB_UPLOAD_CONFIG); $xml = file_get_contents(self::get_fixture_path(__NAMESPACE__, 'unencrypted.seb')); $this->create_module_test_file($xml, $this->quiz->cmid); $quizsettings->save(); // Set up dummy request. $FULLME = 'https://example.com/moodle/mod/quiz/attempt.php?attemptid=123&page=4'; $_SERVER['HTTP_USER_AGENT'] = 'SEB'; $_SERVER['HTTP_X_SAFEEXAMBROWSER_CONFIGKEYHASH'] = 'Broken config key'; $user = $this->getDataGenerator()->create_user(); $this->setUser($user); $this->check_invalid_config_key(); } /** * Test access prevented if config keys is invalid and using template. */ public function test_access_prevented_if_config_key_invalid_uploaded_template(): void { global $FULLME; $this->setAdminUser(); $this->quiz = $this->create_test_quiz($this->course, settings_provider::USE_SEB_CONFIG_MANUALLY); // Set quiz setting to require seb and save BEK. $quizsettings = seb_quiz_settings::get_record(['quizid' => $this->quiz->id]); $quizsettings->set('requiresafeexambrowser', settings_provider::USE_SEB_TEMPLATE); $quizsettings->set('templateid', $this->create_template()->get('id')); $quizsettings->save(); // Set up dummy request. $FULLME = 'https://example.com/moodle/mod/quiz/attempt.php?attemptid=123&page=4'; $_SERVER['HTTP_USER_AGENT'] = 'SEB'; $_SERVER['HTTP_X_SAFEEXAMBROWSER_CONFIGKEYHASH'] = 'Broken config key'; $user = $this->getDataGenerator()->create_user(); $this->setUser($user); $this->check_invalid_config_key(); } /** * Test access not prevented if config key matches header. */ public function test_access_allowed_if_config_key_valid(): void { global $FULLME; $this->setAdminUser(); $this->quiz = $this->create_test_quiz($this->course, settings_provider::USE_SEB_CONFIG_MANUALLY); $user = $this->getDataGenerator()->create_user(); $this->setUser($user); // Set quiz setting to require seb. $quizsettings = seb_quiz_settings::get_record(['quizid' => $this->quiz->id]); // Set up dummy request. $FULLME = 'https://example.com/moodle/mod/quiz/attempt.php?attemptid=123&page=4'; $expectedhash = hash('sha256', $FULLME . $quizsettings->get_config_key()); $_SERVER['HTTP_X_SAFEEXAMBROWSER_CONFIGKEYHASH'] = $expectedhash; // Check that correct error message is returned. $this->assertFalse($this->make_rule()->prevent_access()); } /** * Test access not prevented if config key matches header. */ public function test_access_allowed_if_config_key_valid_uploaded_config(): void { global $FULLME; $this->setAdminUser(); $this->quiz = $this->create_test_quiz($this->course, settings_provider::USE_SEB_CONFIG_MANUALLY); // Set quiz setting to require seb and save BEK. $quizsettings = seb_quiz_settings::get_record(['quizid' => $this->quiz->id]); $quizsettings->set('requiresafeexambrowser', settings_provider::USE_SEB_TEMPLATE); $quizsettings->set('templateid', $this->create_template()->get('id')); $quizsettings->save(); $user = $this->getDataGenerator()->create_user(); $this->setUser($user); // Set up dummy request. $FULLME = 'https://example.com/moodle/mod/quiz/attempt.php?attemptid=123&page=4'; $expectedhash = hash('sha256', $FULLME . $quizsettings->get_config_key()); $_SERVER['HTTP_X_SAFEEXAMBROWSER_CONFIGKEYHASH'] = $expectedhash; // Check that correct error message is returned. $this->assertFalse($this->make_rule()->prevent_access()); } /** * Test access not prevented if config key matches header. */ public function test_access_allowed_if_config_key_valid_template(): void { global $FULLME; $this->setAdminUser(); $this->quiz = $this->create_test_quiz($this->course, settings_provider::USE_SEB_CONFIG_MANUALLY); // Set quiz setting to require seb and save BEK. $quizsettings = seb_quiz_settings::get_record(['quizid' => $this->quiz->id]); $quizsettings->set('requiresafeexambrowser', settings_provider::USE_SEB_UPLOAD_CONFIG); $xml = file_get_contents(self::get_fixture_path(__NAMESPACE__, 'unencrypted.seb')); $this->create_module_test_file($xml, $this->quiz->cmid); $quizsettings->save(); $user = $this->getDataGenerator()->create_user(); $this->setUser($user); // Set up dummy request. $FULLME = 'https://example.com/moodle/mod/quiz/attempt.php?attemptid=123&page=4'; $expectedhash = hash('sha256', $FULLME . $quizsettings->get_config_key()); $_SERVER['HTTP_X_SAFEEXAMBROWSER_CONFIGKEYHASH'] = $expectedhash; // Check that correct error message is returned. $this->assertFalse($this->make_rule()->prevent_access()); } /** * Test access not prevented if browser exam keys match headers. */ public function test_access_allowed_if_browser_exam_keys_valid(): void { global $FULLME; $this->setAdminUser(); $this->quiz = $this->create_test_quiz($this->course, settings_provider::USE_SEB_CONFIG_MANUALLY); $user = $this->getDataGenerator()->create_user(); $this->setUser($user); // Set quiz setting to require seb and save BEK. $browserexamkey = hash('sha256', 'testkey'); $quizsettings = seb_quiz_settings::get_record(['quizid' => $this->quiz->id]); $quizsettings->set('requiresafeexambrowser', settings_provider::USE_SEB_CLIENT_CONFIG); // Doesn't check config key. $quizsettings->set('allowedbrowserexamkeys', $browserexamkey); $quizsettings->save(); // Set up dummy request. $FULLME = 'https://example.com/moodle/mod/quiz/attempt.php?attemptid=123&page=4'; $expectedhash = hash('sha256', $FULLME . $browserexamkey); $_SERVER['HTTP_X_SAFEEXAMBROWSER_REQUESTHASH'] = $expectedhash; $_SERVER['HTTP_USER_AGENT'] = 'SEB'; // Check that correct error message is returned. $this->assertFalse($this->make_rule()->prevent_access()); } /** * Test access not prevented if browser exam keys match headers. */ public function test_access_allowed_if_browser_exam_keys_valid_use_uploaded_file(): void { global $FULLME; $this->setAdminUser(); $this->quiz = $this->create_test_quiz($this->course, settings_provider::USE_SEB_CONFIG_MANUALLY); // Set quiz setting to require seb and save BEK. $browserexamkey = hash('sha256', 'testkey'); $quizsettings = seb_quiz_settings::get_record(['quizid' => $this->quiz->id]); $quizsettings->set('requiresafeexambrowser', settings_provider::USE_SEB_UPLOAD_CONFIG); $quizsettings->set('allowedbrowserexamkeys', $browserexamkey); $xml = file_get_contents(self::get_fixture_path(__NAMESPACE__, 'unencrypted.seb')); $this->create_module_test_file($xml, $this->quiz->cmid); $quizsettings->save(); // Set up dummy request. $FULLME = 'https://example.com/moodle/mod/quiz/attempt.php?attemptid=123&page=4'; $expectedbrowserkey = hash('sha256', $FULLME . $browserexamkey); $_SERVER['HTTP_X_SAFEEXAMBROWSER_REQUESTHASH'] = $expectedbrowserkey; $expectedconfigkey = hash('sha256', $FULLME . $quizsettings->get_config_key()); $_SERVER['HTTP_X_SAFEEXAMBROWSER_CONFIGKEYHASH'] = $expectedconfigkey; $user = $this->getDataGenerator()->create_user(); $this->setUser($user); // Check that correct error message is returned. $this->assertFalse($this->make_rule()->prevent_access()); } public function test_access_allowed_if_access_state_stored_in_session(): void { global $SESSION; $this->setAdminUser(); $this->quiz = $this->create_test_quiz($this->course, settings_provider::USE_SEB_CLIENT_CONFIG); $user = $this->getDataGenerator()->create_user(); $this->setUser($user); // Check that access is prevented. $this->check_invalid_basic_header(); $SESSION->quizaccess_seb_access = [$this->quiz->cmid => true]; // Check access is now not prevented. $this->assertFalse($this->make_rule()->prevent_access()); } /** * A helper method to check invalid browser key. * * @param bool $downloadseblink Make sure download SEB link is present. * @param bool $launchlink Make sure launch SEB link is present. * @param bool $downloadconfiglink Make download config link is present. */ protected function check_invalid_browser_exam_key($downloadseblink = true, $launchlink = true, $downloadconfiglink = true) { // Create an event sink, trigger event and retrieve event. $sink = $this->redirectEvents(); // Check that correct error message is returned. $errormsg = $this->make_rule()->prevent_access(); $this->assertNotEmpty($errormsg); $this->assertStringContainsString("The Safe Exam Browser keys could not be validated. " . "Check that you're using Safe Exam Browser with the correct configuration file.", $errormsg); if ($downloadseblink) { $this->assertStringContainsString($this->get_seb_download_link(), $errormsg); } else { $this->assertStringNotContainsString($this->get_seb_download_link(), $errormsg); } if ($launchlink) { $this->assertStringContainsString($this->get_seb_launch_link(), $errormsg); } else { $this->assertStringNotContainsString($this->get_seb_launch_link(), $errormsg); } if ($downloadconfiglink) { $this->assertStringContainsString($this->get_seb_config_download_link(), $errormsg); } else { $this->assertStringNotContainsString($this->get_seb_config_download_link(), $errormsg); } $events = $sink->get_events(); $this->assertEquals(1, count($events)); $event = reset($events); // Test that the event data is as expected. $this->assertInstanceOf('\quizaccess_seb\event\access_prevented', $event); $this->assertEquals('Invalid SEB browser key', $event->other['reason']); } /** * Test access prevented if browser exam keys do not match headers. */ public function test_access_prevented_if_browser_exam_keys_are_invalid(): void { $this->setAdminUser(); $this->quiz = $this->create_test_quiz($this->course, settings_provider::USE_SEB_CONFIG_MANUALLY); $user = $this->getDataGenerator()->create_user(); $this->setUser($user); // Set quiz setting to require seb and save BEK. $browserexamkey = hash('sha256', 'testkey'); $quizsettings = seb_quiz_settings::get_record(['quizid' => $this->quiz->id]); $quizsettings->set('requiresafeexambrowser', settings_provider::USE_SEB_CLIENT_CONFIG); // Doesn't check config key. $quizsettings->set('allowedbrowserexamkeys', $browserexamkey); $quizsettings->save(); // Set up dummy request. $_SERVER['HTTP_X_SAFEEXAMBROWSER_REQUESTHASH'] = 'Broken browser key'; $_SERVER['HTTP_USER_AGENT'] = 'SEB'; $this->check_invalid_browser_exam_key(true, false, false); } /** * Test access prevented if browser exam keys do not match headers and using uploaded config. */ public function test_access_prevented_if_browser_exam_keys_are_invalid_use_uploaded_file(): void { global $FULLME; $this->setAdminUser(); $this->quiz = $this->create_test_quiz($this->course, settings_provider::USE_SEB_CONFIG_MANUALLY); // Set quiz setting to require seb and save BEK. $browserexamkey = hash('sha256', 'testkey'); $quizsettings = seb_quiz_settings::get_record(['quizid' => $this->quiz->id]); $quizsettings->set('requiresafeexambrowser', settings_provider::USE_SEB_UPLOAD_CONFIG); $quizsettings->set('allowedbrowserexamkeys', $browserexamkey); $xml = file_get_contents(self::get_fixture_path(__NAMESPACE__, 'unencrypted.seb')); $this->create_module_test_file($xml, $this->quiz->cmid); $quizsettings->save(); // Set up dummy request. $FULLME = 'https://example.com/moodle/mod/quiz/attempt.php?attemptid=123&page=4'; $expectedhash = hash('sha256', $FULLME . $quizsettings->get_config_key()); $_SERVER['HTTP_USER_AGENT'] = 'SEB'; $_SERVER['HTTP_X_SAFEEXAMBROWSER_CONFIGKEYHASH'] = $expectedhash; // Set up broken browser key. $_SERVER['HTTP_X_SAFEEXAMBROWSER_REQUESTHASH'] = 'Broken browser key'; $user = $this->getDataGenerator()->create_user(); $this->setUser($user); $this->check_invalid_browser_exam_key(); } /** * Test access not prevented if browser exam keys do not match headers and using template. */ public function test_access_prevented_if_browser_exam_keys_are_invalid_use_template(): void { global $FULLME; $this->setAdminUser(); $this->quiz = $this->create_test_quiz($this->course, settings_provider::USE_SEB_CONFIG_MANUALLY); // Set quiz setting to require seb and save BEK. $browserexamkey = hash('sha256', 'testkey'); $quizsettings = seb_quiz_settings::get_record(['quizid' => $this->quiz->id]); $quizsettings->set('requiresafeexambrowser', settings_provider::USE_SEB_TEMPLATE); $quizsettings->set('allowedbrowserexamkeys', $browserexamkey); $quizsettings->set('templateid', $this->create_template()->get('id')); $quizsettings->save(); // Set up dummy request. $FULLME = 'https://example.com/moodle/mod/quiz/attempt.php?attemptid=123&page=4'; $expectedhash = hash('sha256', $FULLME . $quizsettings->get_config_key()); $_SERVER['HTTP_X_SAFEEXAMBROWSER_CONFIGKEYHASH'] = $expectedhash; // Set up broken browser key. $_SERVER['HTTP_X_SAFEEXAMBROWSER_REQUESTHASH'] = 'Broken browser key'; $user = $this->getDataGenerator()->create_user(); $this->setUser($user); // Check that correct error message is returned. $this->assertFalse($this->make_rule()->prevent_access()); } /** * Test access allowed if using client configuration and SEB user agent header is valid. */ public function test_access_allowed_if_using_client_config_basic_header_is_valid(): void { $this->setAdminUser(); $this->quiz = $this->create_test_quiz($this->course, settings_provider::USE_SEB_CONFIG_MANUALLY); $user = $this->getDataGenerator()->create_user(); $this->setUser($user); // Set quiz setting to require seb. $quizsettings = seb_quiz_settings::get_record(['quizid' => $this->quiz->id]); $quizsettings->set('requiresafeexambrowser', settings_provider::USE_SEB_CLIENT_CONFIG); // Doesn't check config key. $quizsettings->save(); // Set up basic dummy request. $_SERVER['HTTP_USER_AGENT'] = 'SEB_TEST_SITE'; // Check that correct error message is returned. $this->assertFalse($this->make_rule()->prevent_access()); } /** * Test access prevented if using client configuration and SEB user agent header is invalid. */ public function test_access_prevented_if_using_client_configuration_and_basic_head_is_invalid(): void { $this->setAdminUser(); $this->quiz = $this->create_test_quiz($this->course, settings_provider::USE_SEB_CONFIG_MANUALLY); $user = $this->getDataGenerator()->create_user(); $this->setUser($user); // Set quiz setting to require seb. $quizsettings = seb_quiz_settings::get_record(['quizid' => $this->quiz->id]); $quizsettings->set('requiresafeexambrowser', settings_provider::USE_SEB_CLIENT_CONFIG); // Doesn't check config key. $quizsettings->save(); // Set up basic dummy request. $_SERVER['HTTP_USER_AGENT'] = 'WRONG_TEST_SITE'; // Create an event sink, trigger event and retrieve event. $this->check_invalid_basic_header(); } /** * A helper method to check invalid basic header. */ protected function check_invalid_basic_header() { // Create an event sink, trigger event and retrieve event. $sink = $this->redirectEvents(); // Check that correct error message is returned. $this->assertStringContainsString( 'This quiz has been configured to use the Safe Exam Browser with client configuration.', $this->make_rule()->prevent_access() ); $events = $sink->get_events(); $this->assertEquals(1, count($events)); $event = reset($events); // Test that the event data is as expected. $this->assertInstanceOf('\quizaccess_seb\event\access_prevented', $event); $this->assertEquals('No Safe Exam Browser is being used.', $event->other['reason']); } /** * Test access allowed if using client configuration and SEB user agent header is invalid and use uploaded file. */ public function test_access_allowed_if_using_client_configuration_and_basic_head_is_invalid_use_uploaded_config(): void { global $FULLME; $this->setAdminUser(); $this->quiz = $this->create_test_quiz($this->course, settings_provider::USE_SEB_CONFIG_MANUALLY); // Set quiz setting to require seb. $quizsettings = seb_quiz_settings::get_record(['quizid' => $this->quiz->id]); $quizsettings->set('requiresafeexambrowser', settings_provider::USE_SEB_UPLOAD_CONFIG); // Doesn't check basic header. $xml = file_get_contents(self::get_fixture_path(__NAMESPACE__, 'unencrypted.seb')); $this->create_module_test_file($xml, $this->quiz->cmid); $quizsettings->save(); // Set up dummy request. $FULLME = 'https://example.com/moodle/mod/quiz/attempt.php?attemptid=123&page=4'; $expectedhash = hash('sha256', $FULLME . $quizsettings->get_config_key()); $_SERVER['HTTP_X_SAFEEXAMBROWSER_CONFIGKEYHASH'] = $expectedhash; $_SERVER['HTTP_USER_AGENT'] = 'WRONG_TEST_SITE'; $user = $this->getDataGenerator()->create_user(); $this->setUser($user); // Check that correct error message is returned. $this->assertFalse($this->make_rule()->prevent_access()); } /** * Test access allowed if using client configuration and SEB user agent header is invalid and use template. */ public function test_access_allowed_if_using_client_configuration_and_basic_head_is_invalid_use_template(): void { global $FULLME; $this->setAdminUser(); $this->quiz = $this->create_test_quiz($this->course, settings_provider::USE_SEB_CONFIG_MANUALLY); // Set quiz setting to require seb. $quizsettings = seb_quiz_settings::get_record(['quizid' => $this->quiz->id]); $quizsettings->set('requiresafeexambrowser', settings_provider::USE_SEB_TEMPLATE); $quizsettings->set('templateid', $this->create_template()->get('id')); $quizsettings->save(); // Set up dummy request. $FULLME = 'https://example.com/moodle/mod/quiz/attempt.php?attemptid=123&page=4'; $expectedhash = hash('sha256', $FULLME . $quizsettings->get_config_key()); $_SERVER['HTTP_X_SAFEEXAMBROWSER_CONFIGKEYHASH'] = $expectedhash; $_SERVER['HTTP_USER_AGENT'] = 'WRONG_TEST_SITE'; $user = $this->getDataGenerator()->create_user(); $this->setUser($user); // Check that correct error message is returned. $this->assertFalse($this->make_rule()->prevent_access()); } /** * Test access not prevented if SEB not required. */ public function test_access_allowed_if_seb_not_required(): void { $this->setAdminUser(); $this->quiz = $this->create_test_quiz($this->course, settings_provider::USE_SEB_CONFIG_MANUALLY); $user = $this->getDataGenerator()->create_user(); $this->setUser($user); // Set quiz setting to not require seb. $quizsettings = seb_quiz_settings::get_record(['quizid' => $this->quiz->id]); $quizsettings->set('requiresafeexambrowser', settings_provider::USE_SEB_NO); $quizsettings->save(); // The rule will not exist as the settings are not configured for SEB usage. $this->assertNull($this->make_rule()); } /** * Test access not prevented if USER has bypass capability. */ public function test_access_allowed_if_user_has_bypass_capability(): void { $this->setAdminUser(); $this->quiz = $this->create_test_quiz($this->course, settings_provider::USE_SEB_CONFIG_MANUALLY); $user = $this->getDataGenerator()->create_user(); $this->setUser($user); // Set the bypass SEB check capability to $USER. $this->assign_user_capability('quizaccess/seb:bypassseb', \context_module::instance($this->quiz->cmid)->id); // Check that correct error message is returned. $this->assertFalse($this->make_rule()->prevent_access()); } /** * Test that quiz form cannot be saved if using template, but not actually pick one. */ public function test_mod_quiz_form_cannot_be_saved_using_template_and_template_is_not_set(): void { $this->setAdminUser(); $this->quiz = $this->create_test_quiz($this->course, settings_provider::USE_SEB_CONFIG_MANUALLY); $form = $this->createMock('mod_quiz_mod_form'); $form->method('get_context')->willReturn(\context_module::instance($this->quiz->cmid)); // Validate settings with a dummy form. $errors = quizaccess_seb::validate_settings_form_fields([], [ 'instance' => $this->quiz->id, 'coursemodule' => $this->quiz->cmid, 'seb_requiresafeexambrowser' => settings_provider::USE_SEB_TEMPLATE ], [], $form); $this->assertContains(get_string('invalidtemplate', 'quizaccess_seb'), $errors); } /** * Test that quiz form cannot be saved if uploaded invalid file. */ public function test_mod_quiz_form_cannot_be_saved_using_uploaded_file_and_file_is_not_valid(): void { $this->setAdminUser(); $this->quiz = $this->create_test_quiz($this->course, settings_provider::USE_SEB_CONFIG_MANUALLY); $form = $this->createMock('mod_quiz_mod_form'); $form->method('get_context')->willReturn(\context_module::instance($this->quiz->cmid)); // Validate settings with a dummy form. $errors = quizaccess_seb::validate_settings_form_fields([], [ 'instance' => $this->quiz->id, 'coursemodule' => $this->quiz->cmid, 'seb_requiresafeexambrowser' => settings_provider::USE_SEB_UPLOAD_CONFIG, 'filemanager_sebconfigfile' => 0, ], [], $form); $this->assertContainsEquals(get_string('filenotpresent', 'quizaccess_seb'), $errors); } /** * Test that quiz form cannot be saved if the global settings are set to require a password and no password is set. */ public function test_mod_quiz_form_cannot_be_saved_if_global_settings_force_quiz_password_and_none_is_set(): void { $this->setAdminUser(); // Set global settings to require quiz password but set password to be empty. set_config('quizpasswordrequired', '1', 'quizaccess_seb'); $this->quiz = $this->create_test_quiz($this->course, settings_provider::USE_SEB_CONFIG_MANUALLY); $form = $this->createMock('mod_quiz_mod_form'); $form->method('get_context')->willReturn(\context_module::instance($this->quiz->cmid)); // Validate settings with a dummy form. $errors = quizaccess_seb::validate_settings_form_fields([], [ 'instance' => $this->quiz->id, 'coursemodule' => $this->quiz->cmid, 'seb_requiresafeexambrowser' => settings_provider::USE_SEB_CONFIG_MANUALLY, ], [], $form); $this->assertContains(get_string('passwordnotset', 'quizaccess_seb'), $errors); } /** * Test that access to quiz is allowed if global setting is set to restrict quiz if no quiz password is set, and global quiz * password is set. */ public function test_mod_quiz_form_can_be_saved_if_global_settings_force_quiz_password_and_is_set(): void { $this->setAdminUser(); // Set global settings to require quiz password but set password to be empty. set_config('quizpasswordrequired', '1', 'quizaccess_seb'); $this->quiz = $this->create_test_quiz($this->course, settings_provider::USE_SEB_CONFIG_MANUALLY); $form = $this->createMock('mod_quiz_mod_form'); $form->method('get_context')->willReturn(\context_module::instance($this->quiz->cmid)); // Validate settings with a dummy form. $errors = quizaccess_seb::validate_settings_form_fields([], [ 'instance' => $this->quiz->id, 'coursemodule' => $this->quiz->cmid, 'quizpassword' => 'set', 'seb_requiresafeexambrowser' => settings_provider::USE_SEB_CONFIG_MANUALLY, ], [], $form); $this->assertNotContains(get_string('passwordnotset', 'quizaccess_seb'), $errors); } /** * Test that quiz form can be saved if the global settings are set to require a password and no seb usage selected. */ public function test_mod_quiz_form_can_be_saved_if_global_settings_force_quiz_password_and_none_no_seb(): void { $this->setAdminUser(); // Set global settings to require quiz password but set password to be empty. set_config('quizpasswordrequired', '1', 'quizaccess_seb'); $this->quiz = $this->create_test_quiz($this->course, settings_provider::USE_SEB_NO); $form = $this->createMock('mod_quiz_mod_form'); $form->method('get_context')->willReturn(\context_module::instance($this->quiz->cmid)); // Validate settings with a dummy form. $errors = quizaccess_seb::validate_settings_form_fields([], [ 'instance' => $this->quiz->id, 'coursemodule' => $this->quiz->cmid, 'seb_requiresafeexambrowser' => settings_provider::USE_SEB_NO, ], [], $form); $this->assertNotContains(get_string('passwordnotset', 'quizaccess_seb'), $errors); } /** * Test get_download_seb_button, checks for empty config setting quizaccess_seb/downloadlink. */ public function test_get_download_seb_button(): void { $this->setAdminUser(); $this->quiz = $this->create_test_quiz($this->course, settings_provider::USE_SEB_CONFIG_MANUALLY); $user = $this->getDataGenerator()->create_user(); $this->setUser($user); $reflection = new \ReflectionClass('quizaccess_seb'); $method = $reflection->getMethod('get_download_seb_button'); // The current default contents. $this->assertStringContainsString($this->get_seb_download_link(), $method->invoke($this->make_rule())); set_config('downloadlink', '', 'quizaccess_seb'); // Will not return any button if the URL is empty. $this->assertSame('', $method->invoke($this->make_rule())); } /** * Test get_download_seb_button shows download SEB link when required, */ public function test_get_get_action_buttons_shows_download_seb_link(): void { $this->setAdminUser(); $this->quiz = $this->create_test_quiz($this->course, settings_provider::USE_SEB_CONFIG_MANUALLY); $user = $this->getDataGenerator()->create_user(); $this->setUser($user); $reflection = new \ReflectionClass('quizaccess_seb'); $method = $reflection->getMethod('get_action_buttons'); $this->assertStringContainsString($this->get_seb_download_link(), $method->invoke($this->make_rule())); $this->quiz->seb_showsebdownloadlink = 0; $this->assertStringNotContainsString($this->get_seb_download_link(), $method->invoke($this->make_rule())); } /** * Test get_download_seb_button shows SEB config related links when required. */ public function test_get_get_action_buttons_shows_launch_and_download_config_links(): void { $this->setAdminUser(); $this->quiz = $this->create_test_quiz($this->course, settings_provider::USE_SEB_CONFIG_MANUALLY); $user = $this->getDataGenerator()->create_user(); $this->setUser($user); $reflection = new \ReflectionClass('quizaccess_seb'); $method = $reflection->getMethod('get_action_buttons'); $quizsettings = seb_quiz_settings::get_record(['quizid' => $this->quiz->id]); // Should see link when using manually. $this->assertStringContainsString($this->get_seb_launch_link(), $method->invoke($this->make_rule())); $this->assertStringContainsString($this->get_seb_config_download_link(), $method->invoke($this->make_rule())); // Should see links when using template. $quizsettings->set('requiresafeexambrowser', settings_provider::USE_SEB_TEMPLATE); $quizsettings->set('templateid', $this->create_template()->get('id')); $quizsettings->save(); $this->assertStringContainsString($this->get_seb_launch_link(), $method->invoke($this->make_rule())); $this->assertStringContainsString($this->get_seb_config_download_link(), $method->invoke($this->make_rule())); // Should see links when using uploaded config. $quizsettings->set('requiresafeexambrowser', settings_provider::USE_SEB_UPLOAD_CONFIG); $xml = file_get_contents(self::get_fixture_path(__NAMESPACE__, 'unencrypted.seb')); $this->create_module_test_file($xml, $this->quiz->cmid); $quizsettings->save(); $this->assertStringContainsString($this->get_seb_launch_link(), $method->invoke($this->make_rule())); $this->assertStringContainsString($this->get_seb_config_download_link(), $method->invoke($this->make_rule())); // Shouldn't see links if using client config. $quizsettings->set('requiresafeexambrowser', settings_provider::USE_SEB_CLIENT_CONFIG); $quizsettings->save(); $this->assertStringNotContainsString($this->get_seb_launch_link(), $method->invoke($this->make_rule())); $this->assertStringNotContainsString($this->get_seb_config_download_link(), $method->invoke($this->make_rule())); } /** * Test get_download_seb_button shows SEB config related links as configured in "showseblinks". */ public function test_get_get_action_buttons_shows_launch_and_download_config_links_as_configured(): void { $this->setAdminUser(); $this->quiz = $this->create_test_quiz($this->course, settings_provider::USE_SEB_CONFIG_MANUALLY); $user = $this->getDataGenerator()->create_user(); $this->setUser($user); $reflection = new \ReflectionClass('quizaccess_seb'); $method = $reflection->getMethod('get_action_buttons'); set_config('showseblinks', 'seb,http', 'quizaccess_seb'); $this->assertStringContainsString($this->get_seb_launch_link(), $method->invoke($this->make_rule())); $this->assertStringContainsString($this->get_seb_config_download_link(), $method->invoke($this->make_rule())); set_config('showseblinks', 'http', 'quizaccess_seb'); $this->assertStringNotContainsString($this->get_seb_launch_link(), $method->invoke($this->make_rule())); $this->assertStringContainsString($this->get_seb_config_download_link(), $method->invoke($this->make_rule())); set_config('showseblinks', 'seb', 'quizaccess_seb'); $this->assertStringContainsString($this->get_seb_launch_link(), $method->invoke($this->make_rule())); $this->assertStringNotContainsString($this->get_seb_config_download_link(), $method->invoke($this->make_rule())); set_config('showseblinks', '', 'quizaccess_seb'); $this->assertStringNotContainsString($this->get_seb_launch_link(), $method->invoke($this->make_rule())); $this->assertStringNotContainsString($this->get_seb_config_download_link(), $method->invoke($this->make_rule())); } /** * Test get_quit_button. If attempt count is greater than 0 */ public function test_get_quit_button(): void { $this->setAdminUser(); $this->quiz = $this->create_test_quiz($this->course, settings_provider::USE_SEB_CLIENT_CONFIG); $this->quiz->seb_linkquitseb = "http://test.quit.link"; $user = $this->getDataGenerator()->create_user(); $this->attempt_quiz($this->quiz, $user); $this->setUser($user); // Set-up the button to be called. $reflection = new \ReflectionClass('quizaccess_seb'); $method = $reflection->getMethod('get_quit_button'); $button = $method->invoke($this->make_rule()); $this->assertStringContainsString("http://test.quit.link", $button); } /** * Test description, checks for a valid SEB session and attempt count . */ public function test_description(): void { $this->setAdminUser(); $this->quiz = $this->create_test_quiz($this->course, settings_provider::USE_SEB_CLIENT_CONFIG); $this->quiz->seb_linkquitseb = "http://test.quit.link"; // Set up basic dummy request. $_SERVER['HTTP_USER_AGENT'] = 'SEB_TEST_SITE'; $user = $this->getDataGenerator()->create_user(); $this->attempt_quiz($this->quiz, $user); $description = $this->make_rule()->description(); $this->assertCount(2, $description); $this->assertEquals($description[0], get_string('sebrequired', 'quizaccess_seb')); $this->assertEquals($description[1], ''); // Set the user as display_quit_button() uses the global $USER. $this->setUser($user); $description = $this->make_rule()->description(); $this->assertCount(2, $description); $this->assertEquals($description[0], get_string('sebrequired', 'quizaccess_seb')); // The button is contained in the description when a quiz attempt is finished. $this->assertStringContainsString("http://test.quit.link", $description[1]); } /** * Test description displays download SEB config button when required. */ public function test_description_shows_download_config_link_when_required(): void { $this->setAdminUser(); $this->quiz = $this->create_test_quiz($this->course, settings_provider::USE_SEB_CONFIG_MANUALLY); $quizsettings = seb_quiz_settings::get_record(['quizid' => $this->quiz->id]); $user = $this->getDataGenerator()->create_user(); $roleid = $this->getDataGenerator()->create_role(); $context = \context_module::instance($this->quiz->cmid); assign_capability('quizaccess/seb:bypassseb', CAP_ALLOW, $roleid, $context->id); $this->setUser($user); // Can see just basic description with standard perms. $description = $this->make_rule()->description(); $this->assertCount(1, $description); $this->assertEquals($description[0], get_string('sebrequired', 'quizaccess_seb')); // Can see download config link as have bypass SEB permissions. $this->getDataGenerator()->role_assign($roleid, $user->id, $context->id); $description = $this->make_rule()->description(); $this->assertCount(3, $description); $this->assertEquals($description[0], get_string('sebrequired', 'quizaccess_seb')); $this->assertStringContainsString($this->get_seb_config_download_link(), $description[1]); // Can't see download config link as usage method doesn't have SEB config to download. $quizsettings->set('requiresafeexambrowser', settings_provider::USE_SEB_CLIENT_CONFIG); $quizsettings->save(); $description = $this->make_rule()->description(); $this->assertCount(2, $description); $this->assertEquals($description[0], get_string('sebrequired', 'quizaccess_seb')); } /** * Test block display before a quiz started. */ public function test_blocks_display_before_attempt_started(): void { global $PAGE; $this->setAdminUser(); $this->quiz = $this->create_test_quiz($this->course, settings_provider::USE_SEB_CLIENT_CONFIG); $user = $this->getDataGenerator()->create_user(); $this->setUser($user); // We will check if we show only fake blocks. Which means no other blocks on a page. $reflection = new \ReflectionClass('block_manager'); $property = $reflection->getProperty('fakeblocksonly'); $this->assertFalse($property->getValue($PAGE->blocks)); // Don't display blocks before start. set_config('displayblocksbeforestart', 0, 'quizaccess_seb'); $this->set_up_quiz_view_page(); $this->make_rule()->prevent_access(); $this->assertEquals('secure', $PAGE->pagelayout); $this->assertTrue($property->getValue($PAGE->blocks)); // Display blocks before start. set_config('displayblocksbeforestart', 1, 'quizaccess_seb'); $this->set_up_quiz_view_page(); $this->make_rule()->prevent_access(); $this->assertEquals('secure', $PAGE->pagelayout); $this->assertFalse($property->getValue($PAGE->blocks)); } /** * Test block display after a quiz completed. */ public function test_blocks_display_after_attempt_finished(): void { global $PAGE; $this->setAdminUser(); $this->quiz = $this->create_test_quiz($this->course, settings_provider::USE_SEB_CLIENT_CONFIG); // Finish the quiz. $user = $this->getDataGenerator()->create_user(); $this->attempt_quiz($this->quiz, $user); $this->setUser($user); // We will check if we show only fake blocks. Which means no other blocks on a page. $reflection = new \ReflectionClass('block_manager'); $property = $reflection->getProperty('fakeblocksonly'); $this->assertFalse($property->getValue($PAGE->blocks)); // Don't display blocks after finish. set_config('displayblockswhenfinished', 0, 'quizaccess_seb'); $this->set_up_quiz_view_page(); $this->make_rule()->prevent_access(); $this->assertEquals('secure', $PAGE->pagelayout); $this->assertTrue($property->getValue($PAGE->blocks)); // Display blocks after finish. set_config('displayblockswhenfinished', 1, 'quizaccess_seb'); $this->set_up_quiz_view_page(); $this->make_rule()->prevent_access(); $this->assertEquals('secure', $PAGE->pagelayout); $this->assertFalse($property->getValue($PAGE->blocks)); } /** * Test cleanup when quiz is completed. */ public function test_current_attempt_finished(): void { global $SESSION; $this->setAdminUser(); $this->quiz = $this->create_test_quiz($this->course, settings_provider::USE_SEB_CONFIG_MANUALLY); $quizsettings = seb_quiz_settings::get_record(['quizid' => $this->quiz->id]); $quizsettings->save(); // Set access for Moodle session. $SESSION->quizaccess_seb_access = [$this->quiz->cmid => true]; $this->make_rule()->current_attempt_finished(); $this->assertTrue(empty($SESSION->quizaccess_seb_access[$this->quiz->cmid])); } } PK9B\ ^^&accessrule/seb/tests/generator/lib.phpnu[. /** * Data generator the quizaccess_seb plugin. * * @package quizaccess_seb * @author Dmitrii Metelkin * @copyright 2020 Catalyst IT * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); /** * Data generator the quizaccess_seb plugin. * * @package quizaccess_seb * @author Dmitrii Metelkin * @copyright 2020 Catalyst IT * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class quizaccess_seb_generator extends component_generator_base { /** * Create SEB template. * * @param array $data Template data. * @return \quizaccess_seb\template */ public function create_template(array $data) { global $CFG; if (!isset($data['name'])) { $data['name'] = 'test'; } if (!isset($data['content'])) { $data['content'] = file_get_contents( $CFG->dirroot . '/mod/quiz/accessrule/seb/tests/fixtures/unencrypted.seb' ); } if (!isset($data['enabled'])) { $data['enabled'] = 1; } $template = new \quizaccess_seb\template(); $template->set('content', $data['content']); $template->set('name', $data['name']); $template->set('enabled', $data['enabled']); $template->save(); return $template; } } PK9B\o\\Aaccessrule/seb/tests/generator/behat_quizaccess_seb_generator.phpnu[. /** * Behat data generator the quizaccess_seb plugin. * * @package quizaccess_seb * @author Dmitrii Metelkin * @copyright 2020 Catalyst IT * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); /** * Behat data generator for quizaccess_seb. * * @copyright 2020 Catalyst IT * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class behat_quizaccess_seb_generator extends behat_generator_base { /** * Get a list of the entities that can be created. * @return array entity name => information about how to generate. */ protected function get_creatable_entities(): array { return [ 'seb templates' => [ 'singular' => 'seb template', 'datagenerator' => 'template', 'required' => ['name'], ], ]; } } PK9B\np'p';accessrule/seb/tests/external/validate_quiz_access_test.phpnu[. namespace quizaccess_seb\external; use quizaccess_seb\seb_quiz_settings; defined('MOODLE_INTERNAL') || die(); require_once(__DIR__ . '/../test_helper_trait.php'); /** * PHPUnit tests for external function. * * @package quizaccess_seb * @author Andrew Madden * @copyright 2021 Catalyst IT * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @covers \quizaccess_seb\external\validate_quiz_access */ final class validate_quiz_access_test extends \advanced_testcase { use \quizaccess_seb_test_helper_trait; /** * This method runs before every test. */ public function setUp(): void { parent::setUp(); $this->resetAfterTest(); // Generate data objects. $this->course = $this->getDataGenerator()->create_course(); $this->quiz = $this->create_test_quiz($this->course); $this->user = $this->getDataGenerator()->create_user(); $this->getDataGenerator()->enrol_user($this->user->id, $this->course->id, 'student'); $this->setUser($this->user); } /** * Bad parameter provider. * * @return array */ public static function bad_parameters_provider(): array { return [ 'no params' => [ 'cmid' => null, 'url' => null, 'configkey' => null, 'messageregex' => '/Invalid parameter value detected \(Missing required key in single structure: cmid\)/' ], 'no course module id' => [ 'cmid' => null, 'url' => 'https://www.example.com/moodle', 'configkey' => hash('sha256', 'configkey'), 'messageregex' => '/Invalid parameter value detected \(Missing required key in single structure: cmid\)/' ], 'no url' => [ 'cmid' => 123, 'url' => null, 'configkey' => hash('sha256', 'configkey'), 'messageregex' => '/Invalid parameter value detected \(Missing required key in single structure: url\)/' ], 'cmid is not an int' => [ 'cmid' => 'test', 'url' => 'https://www.example.com/moodle', 'configkey' => null, 'messageregex' => '/Invalid external api parameter: the value is "test", the server was expecting "int" type/' ], 'url is not a url' => [ 'cmid' => 123, 'url' => 123, 'configkey' => hash('sha256', 'configkey'), 'messageregex' => '/Invalid external api parameter: the value is "123", the server was expecting "url" type/' ], ]; } /** * Test exception thrown for bad parameters. * * @param mixed $cmid Course module id. * @param mixed $url Page URL. * @param mixed $configkey SEB config key. * @param mixed $messageregex Error message regex to check. * * @dataProvider bad_parameters_provider */ public function test_invalid_parameters($cmid, $url, $configkey, $messageregex): void { $params = []; if (!empty($cmid)) { $params['cmid'] = $cmid; } if (!empty($url)) { $params['url'] = $url; } if (!empty($configkey)) { $params['configkey'] = $configkey; } $this->expectException(\invalid_parameter_exception::class); $this->expectExceptionMessageMatches($messageregex); \core_external\external_api::validate_parameters(validate_quiz_keys::execute_parameters(), $params); } /** * Test that the user has permissions to access context. */ public function test_context_is_not_valid_for_user(): void { // Set user as user not enrolled in course and quiz. $this->user = $this->getDataGenerator()->create_user(); $this->setUser($this->user); $this->expectException(\require_login_exception::class); $this->expectExceptionMessage('Course or activity not accessible. (Not enrolled)'); validate_quiz_keys::execute($this->quiz->cmid, 'https://www.example.com/moodle', 'configkey'); } /** * Test exception thrown when no key provided. */ public function test_no_keys_provided(): void { $this->expectException(\invalid_parameter_exception::class); $this->expectExceptionMessage('At least one Safe Exam Browser key must be provided.'); validate_quiz_keys::execute($this->quiz->cmid, 'https://www.example.com/moodle'); } /** * Test exception thrown if cmid doesn't match a quiz. */ public function test_quiz_does_not_exist(): void { $this->setAdminUser(); $forum = $this->getDataGenerator()->create_module('forum', ['course' => $this->course->id]); $this->expectException(\invalid_parameter_exception::class); $this->expectExceptionMessage('Quiz not found matching course module ID: ' . $forum->cmid); validate_quiz_keys::execute($forum->cmid, 'https://www.example.com/moodle', 'configkey'); } /** * Test config key is valid. */ public function test_config_key_valid(): void { $sink = $this->redirectEvents(); // Test settings to populate the quiz. $settings = $this->get_test_settings([ 'quizid' => $this->quiz->id, 'cmid' => $this->quiz->cmid, ]); $url = 'https://www.example.com/moodle'; // Create the quiz settings. $quizsettings = new seb_quiz_settings(0, $settings); $quizsettings->save(); $fullconfigkey = hash('sha256', $url . $quizsettings->get_config_key()); $result = validate_quiz_keys::execute($this->quiz->cmid, $url, $fullconfigkey); $this->assertTrue($result['configkey']); $this->assertTrue($result['browserexamkey']); $events = $sink->get_events(); $this->assertCount(0, $events); } /** * Test config key is not valid. */ public function test_config_key_not_valid(): void { $sink = $this->redirectEvents(); // Test settings to populate the quiz. $settings = $this->get_test_settings([ 'quizid' => $this->quiz->id, 'cmid' => $this->quiz->cmid, ]); // Create the quiz settings. $quizsettings = new seb_quiz_settings(0, $settings); $quizsettings->save(); $result = validate_quiz_keys::execute($this->quiz->cmid, 'https://www.example.com/moodle', 'badconfigkey'); $this->assertFalse($result['configkey']); $this->assertTrue($result['browserexamkey']); $events = $sink->get_events(); $this->assertCount(1, $events); $event = reset($events); $this->assertInstanceOf('\quizaccess_seb\event\access_prevented', $event); $this->assertStringContainsString('Invalid SEB config key', $event->get_description()); } /** * Test browser exam key is valid. */ public function test_browser_exam_key_valid(): void { $sink = $this->redirectEvents(); // Test settings to populate the quiz. $url = 'https://www.example.com/moodle'; $validbrowserexamkey = hash('sha256', 'validbrowserexamkey'); $settings = $this->get_test_settings([ 'quizid' => $this->quiz->id, 'cmid' => $this->quiz->cmid, 'requiresafeexambrowser' => \quizaccess_seb\settings_provider::USE_SEB_CLIENT_CONFIG, 'allowedbrowserexamkeys' => $validbrowserexamkey, ]); // Create the quiz settings. $quizsettings = new seb_quiz_settings(0, $settings); $quizsettings->save(); $fullbrowserexamkey = hash('sha256', $url . $validbrowserexamkey); $result = validate_quiz_keys::execute($this->quiz->cmid, $url, null, $fullbrowserexamkey); $this->assertTrue($result['configkey']); $this->assertTrue($result['browserexamkey']); $events = $sink->get_events(); $this->assertCount(0, $events); } /** * Test browser exam key is not valid. */ public function test_browser_exam_key_not_valid(): void { $sink = $this->redirectEvents(); // Test settings to populate the quiz. $validbrowserexamkey = hash('sha256', 'validbrowserexamkey'); $settings = $this->get_test_settings([ 'quizid' => $this->quiz->id, 'cmid' => $this->quiz->cmid, 'requiresafeexambrowser' => \quizaccess_seb\settings_provider::USE_SEB_CLIENT_CONFIG, 'allowedbrowserexamkeys' => $validbrowserexamkey, ]); // Create the quiz settings. $quizsettings = new seb_quiz_settings(0, $settings); $quizsettings->save(); $result = validate_quiz_keys::execute($this->quiz->cmid, 'https://www.example.com/moodle', null, hash('sha256', 'badbrowserexamkey')); $this->assertTrue($result['configkey']); $this->assertFalse($result['browserexamkey']); $events = $sink->get_events(); $this->assertCount(1, $events); $event = reset($events); $this->assertInstanceOf('\quizaccess_seb\event\access_prevented', $event); $this->assertStringContainsString('Invalid SEB browser key', $event->get_description()); } } PK9B\9H##.accessrule/seb/tests/privacy/provider_test.phpnu[. /** * PHPUnit tests for privacy provider. * * @package quizaccess_seb * @author Andrew Madden * @copyright 2020 Catalyst IT * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace quizaccess_seb\privacy; use core_privacy\local\request\approved_userlist; use core_privacy\local\request\userlist; use core_privacy\local\request\writer; use core_privacy\tests\request\approved_contextlist; use core_privacy\tests\provider_testcase; use quizaccess_seb\privacy\provider; use quizaccess_seb\seb_quiz_settings; defined('MOODLE_INTERNAL') || die(); require_once(__DIR__ . '/../test_helper_trait.php'); /** * PHPUnit tests for privacy provider. * * @copyright 2020 Catalyst IT * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class provider_test extends provider_testcase { use \quizaccess_seb_test_helper_trait; /** * Setup the user, the quiz and ensure that the user is the last user to modify the SEB quiz settings. */ public function setup_test_data() { $this->resetAfterTest(); $this->setAdminUser(); $this->course = $this->getDataGenerator()->create_course(); $this->quiz = $this->create_test_quiz($this->course, \quizaccess_seb\settings_provider::USE_SEB_CONFIG_MANUALLY); $this->user = $this->getDataGenerator()->create_user(); $this->setUser($this->user); $template = $this->create_template(); $quizsettings = seb_quiz_settings::get_record(['quizid' => $this->quiz->id]); // Modify settings so usermodified is updated. This is the user data we are testing for. $quizsettings->set('requiresafeexambrowser', \quizaccess_seb\settings_provider::USE_SEB_TEMPLATE); $quizsettings->set('templateid', $template->get('id')); $quizsettings->save(); } /** * Test that the module context for a user who last modified the module is retrieved. */ public function test_get_contexts_for_userid(): void { $this->setup_test_data(); $contexts = provider::get_contexts_for_userid($this->user->id); $contextids = $contexts->get_contextids(); $this->assertEquals(\context_module::instance($this->quiz->cmid)->id, reset($contextids)); } /** * That that no module context is found for a user who has not modified any quiz settings. */ public function test_get_no_contexts_for_userid(): void { $this->resetAfterTest(); $user = $this->getDataGenerator()->create_user(); $contexts = provider::get_contexts_for_userid($user->id); $contextids = $contexts->get_contextids(); $this->assertEmpty($contextids); } /** * Test that user data is exported in format expected. */ public function test_export_user_data(): void { $this->setup_test_data(); $context = \context_module::instance($this->quiz->cmid); // Add another course_module of a differenty type - doing this lets us // test that the data exporter is correctly limiting its selection to // the quiz and not anything with the same instance id. // (note this is only effective with databases not using fed (+1000) sequences // per table, like postgres and mysql do, rendering this useless. In any // case better to have the situation covered by some DBs, // like sqlsrv than by none). $this->getDataGenerator()->create_module('label', ['course' => $this->course->id]); $contextlist = provider::get_contexts_for_userid($this->user->id); $approvedcontextlist = new approved_contextlist( $this->user, 'quizaccess_seb', $contextlist->get_contextids() ); writer::reset(); /** @var \core_privacy\tests\request\content_writer $writer */ $writer = writer::with_context($context); $this->assertFalse($writer->has_any_data()); provider::export_user_data($approvedcontextlist); $index = '1'; // Get first data returned from the quizsettings table metadata. $data = $writer->get_data([ get_string('pluginname', 'quizaccess_seb'), seb_quiz_settings::TABLE, $index, ]); $this->assertNotEmpty($data); $index = '1'; // Get first data returned from the template table metadata. $data = $writer->get_data([ get_string('pluginname', 'quizaccess_seb'), \quizaccess_seb\template::TABLE, $index, ]); $this->assertNotEmpty($data); $index = '2'; // There should not be more than one instance with data. $data = $writer->get_data([ get_string('pluginname', 'quizaccess_seb'), seb_quiz_settings::TABLE, $index, ]); $this->assertEmpty($data); $index = '2'; // There should not be more than one instance with data. $data = $writer->get_data([ get_string('pluginname', 'quizaccess_seb'), \quizaccess_seb\template::TABLE, $index, ]); $this->assertEmpty($data); } /** * Test that a userlist with module context is populated by usermodified user. */ public function test_get_users_in_context(): void { $this->setup_test_data(); // Create empty userlist with quiz module context. $userlist = new userlist(\context_module::instance($this->quiz->cmid), 'quizaccess_seb'); // Test that the userlist is populated with expected user/s. provider::get_users_in_context($userlist); $this->assertEquals($this->user->id, $userlist->get_userids()[0]); } /** * Test that data is deleted for a list of users. */ public function test_delete_data_for_users(): void { $this->setup_test_data(); $approveduserlist = new approved_userlist(\context_module::instance($this->quiz->cmid), 'quizaccess_seb', [$this->user->id]); // Test data exists. $this->assertNotEmpty(seb_quiz_settings::get_record(['quizid' => $this->quiz->id])); // Test data is deleted. provider::delete_data_for_users($approveduserlist); $record = seb_quiz_settings::get_record(['quizid' => $this->quiz->id]); $this->assertEmpty($record->get('usermodified')); $template = \quizaccess_seb\template::get_record(['id' => $record->get('templateid')]); $this->assertEmpty($template->get('usermodified')); } /** * Test that data is deleted for a list of contexts. */ public function test_delete_data_for_user(): void { $this->setup_test_data(); $context = \context_module::instance($this->quiz->cmid); $approvedcontextlist = new approved_contextlist($this->user, 'quizaccess_seb', [$context->id]); // Test data exists. $this->assertNotEmpty(seb_quiz_settings::get_record(['quizid' => $this->quiz->id])); // Test data is deleted. provider::delete_data_for_user($approvedcontextlist); $record = seb_quiz_settings::get_record(['quizid' => $this->quiz->id]); $this->assertEmpty($record->get('usermodified')); $template = \quizaccess_seb\template::get_record(['id' => $record->get('templateid')]); $this->assertEmpty($template->get('usermodified')); } /** * Test that data is deleted for a single context. */ public function test_delete_data_for_all_users_in_context(): void { $this->setup_test_data(); $context = \context_module::instance($this->quiz->cmid); // Test data exists. $record = seb_quiz_settings::get_record(['quizid' => $this->quiz->id]); $template = \quizaccess_seb\template::get_record(['id' => $record->get('templateid')]); $this->assertNotEmpty($record->get('usermodified')); $this->assertNotEmpty($template->get('usermodified')); // Test data is deleted. provider::delete_data_for_all_users_in_context($context); $record = seb_quiz_settings::get_record(['quizid' => $this->quiz->id]); $template = \quizaccess_seb\template::get_record(['id' => $record->get('templateid')]); $this->assertEmpty($record->get('usermodified')); $this->assertEmpty($template->get('usermodified')); } } PK9B\5]5],accessrule/seb/tests/access_manager_test.phpnu[. namespace quizaccess_seb; defined('MOODLE_INTERNAL') || die(); require_once(__DIR__ . '/test_helper_trait.php'); /** * PHPUnit tests for the access manager. * * @package quizaccess_seb * @author Andrew Madden * @copyright 2020 Catalyst IT * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @covers \quizaccess_seb\seb_access_manager */ final class access_manager_test extends \advanced_testcase { use \quizaccess_seb_test_helper_trait; /** * Called before every test. */ public function setUp(): void { parent::setUp(); $this->resetAfterTest(); $this->setAdminUser(); $this->course = $this->getDataGenerator()->create_course(); } /** * Test access_manager private property quizsettings is null. */ public function test_access_manager_quizsettings_null(): void { $this->quiz = $this->create_test_quiz($this->course); $accessmanager = $this->get_access_manager(); $this->assertFalse($accessmanager->seb_required()); $reflection = new \ReflectionClass('\quizaccess_seb\seb_access_manager'); $property = $reflection->getProperty('quizsettings'); $this->assertFalse($property->getValue($accessmanager)); } /** * Test that SEB is not required. */ public function test_seb_required_false(): void { $this->quiz = $this->create_test_quiz($this->course); $accessmanager = $this->get_access_manager(); $this->assertFalse($accessmanager->seb_required()); } /** * Test that SEB is required. */ public function test_seb_required_true(): void { $this->quiz = $this->create_test_quiz($this->course, settings_provider::USE_SEB_CONFIG_MANUALLY); $accessmanager = $this->get_access_manager(); $this->assertTrue($accessmanager->seb_required()); } /** * Test that user has capability to bypass SEB check. */ public function test_user_can_bypass_seb_check(): void { $this->quiz = $this->create_test_quiz($this->course, settings_provider::USE_SEB_CONFIG_MANUALLY); $user = $this->getDataGenerator()->create_user(); $this->setUser($user); // Set the bypass SEB check capability to $USER. $this->assign_user_capability('quizaccess/seb:bypassseb', \context_module::instance($this->quiz->cmid)->id); $accessmanager = $this->get_access_manager(); $this->assertTrue($accessmanager->can_bypass_seb()); } /** * Test that user has capability to bypass SEB check. */ public function test_admin_user_can_bypass_seb_check(): void { $this->quiz = $this->create_test_quiz($this->course, settings_provider::USE_SEB_CONFIG_MANUALLY); // Test normal user cannot bypass check. $user = $this->getDataGenerator()->create_user(); $this->setUser($user); $accessmanager = $this->get_access_manager(); $this->assertFalse($accessmanager->can_bypass_seb()); // Test with admin user. $this->setAdminUser(); $accessmanager = $this->get_access_manager(); $this->assertTrue($accessmanager->can_bypass_seb()); } /** * Test user does not have capability to bypass SEB check. */ public function test_user_cannot_bypass_seb_check(): void { $this->quiz = $this->create_test_quiz($this->course, settings_provider::USE_SEB_CONFIG_MANUALLY); $user = $this->getDataGenerator()->create_user(); $this->setUser($user); $accessmanager = $this->get_access_manager(); $this->assertFalse($accessmanager->can_bypass_seb()); } /** * Test we can detect SEB usage. */ public function test_is_using_seb(): void { $this->quiz = $this->create_test_quiz($this->course, settings_provider::USE_SEB_CONFIG_MANUALLY); $accessmanager = $this->get_access_manager(); $this->assertFalse($accessmanager->is_using_seb()); $_SERVER['HTTP_USER_AGENT'] = 'Test'; $this->assertFalse($accessmanager->is_using_seb()); $_SERVER['HTTP_USER_AGENT'] = 'SEB'; $this->assertTrue($accessmanager->is_using_seb()); } /** * Test that the quiz Config Key matches the incoming request header. */ public function test_access_keys_validate_with_config_key(): void { global $FULLME; $this->quiz = $this->create_test_quiz($this->course, settings_provider::USE_SEB_CONFIG_MANUALLY); $accessmanager = $this->get_access_manager(); $configkey = seb_quiz_settings::get_record(['quizid' => $this->quiz->id])->get_config_key(); // Set up dummy request. $FULLME = 'https://example.com/moodle/mod/quiz/attempt.php?attemptid=123&page=4'; $expectedhash = hash('sha256', $FULLME . $configkey); $_SERVER['HTTP_X_SAFEEXAMBROWSER_CONFIGKEYHASH'] = $expectedhash; $this->assertTrue($accessmanager->validate_config_key()); } /** * Test that the quiz Config Key matches a provided config key with no incoming request header. */ public function test_access_keys_validate_with_provided_config_key(): void { $this->quiz = $this->create_test_quiz($this->course, settings_provider::USE_SEB_CONFIG_MANUALLY); $url = 'https://www.example.com/moodle'; $accessmanager = $this->get_access_manager(); $configkey = seb_quiz_settings::get_record(['quizid' => $this->quiz->id])->get_config_key(); $fullconfigkey = hash('sha256', $url . $configkey); $this->assertTrue($accessmanager->validate_config_key($fullconfigkey, $url)); } /** * Test that the quiz Config Key does not match the incoming request header. */ public function test_access_keys_fail_to_validate_with_config_key(): void { $this->quiz = $this->create_test_quiz($this->course, settings_provider::USE_SEB_CONFIG_MANUALLY); $accessmanager = $this->get_access_manager(); $this->assertFalse($accessmanager->validate_config_key()); } /** * Test that config key is not checked when using client configuration with SEB. */ public function test_config_key_not_checked_if_client_requirement_is_selected(): void { $this->quiz = $this->create_test_quiz($this->course, settings_provider::USE_SEB_CLIENT_CONFIG); $accessmanager = $this->get_access_manager(); $this->assertFalse($accessmanager->should_validate_config_key()); } /** * Test that if there are no browser exam keys for quiz, check is skipped. */ public function test_no_browser_exam_keys_cause_check_to_be_successful(): void { $this->quiz = $this->create_test_quiz($this->course, settings_provider::USE_SEB_CLIENT_CONFIG); $settings = seb_quiz_settings::get_record(['quizid' => $this->quiz->id]); $settings->set('allowedbrowserexamkeys', ''); $settings->save(); $accessmanager = $this->get_access_manager(); $this->assertTrue($accessmanager->should_validate_browser_exam_key()); $this->assertTrue($accessmanager->validate_browser_exam_key()); } /** * Test that access fails if there is no hash in header. */ public function test_access_keys_fail_if_browser_exam_key_header_does_not_exist(): void { $this->quiz = $this->create_test_quiz($this->course, settings_provider::USE_SEB_CLIENT_CONFIG); $settings = seb_quiz_settings::get_record(['quizid' => $this->quiz->id]); $settings->set('allowedbrowserexamkeys', hash('sha256', 'one') . "\n" . hash('sha256', 'two')); $settings->save(); $accessmanager = $this->get_access_manager(); $this->assertFalse($accessmanager->validate_browser_exam_key()); } /** * Test that access fails if browser exam key doesn't match hash in header. */ public function test_access_keys_fail_if_browser_exam_key_header_does_not_match_provided_hash(): void { $this->quiz = $this->create_test_quiz($this->course, settings_provider::USE_SEB_CLIENT_CONFIG); $settings = seb_quiz_settings::get_record(['quizid' => $this->quiz->id]); $settings->set('allowedbrowserexamkeys', hash('sha256', 'one') . "\n" . hash('sha256', 'two')); $settings->save(); $accessmanager = $this->get_access_manager(); $_SERVER['HTTP_X_SAFEEXAMBROWSER_REQUESTHASH'] = hash('sha256', 'notwhatyouwereexpectinghuh'); $this->assertFalse($accessmanager->validate_browser_exam_key()); } /** * Test that browser exam key matches hash in header. */ public function test_browser_exam_keys_match_header_hash(): void { global $FULLME; $this->quiz = $this->create_test_quiz($this->course, settings_provider::USE_SEB_CLIENT_CONFIG); $settings = seb_quiz_settings::get_record(['quizid' => $this->quiz->id]); $browserexamkey = hash('sha256', 'browserexamkey'); $settings->set('allowedbrowserexamkeys', $browserexamkey); // Add a hashed BEK. $settings->save(); $accessmanager = $this->get_access_manager(); // Set up dummy request. $FULLME = 'https://example.com/moodle/mod/quiz/attempt.php?attemptid=123&page=4'; $expectedhash = hash('sha256', $FULLME . $browserexamkey); $_SERVER['HTTP_X_SAFEEXAMBROWSER_REQUESTHASH'] = $expectedhash; $this->assertTrue($accessmanager->validate_browser_exam_key()); } /** * Test that browser exam key matches a provided browser exam key. */ public function test_browser_exam_keys_match_provided_browser_exam_key(): void { $this->quiz = $this->create_test_quiz($this->course, settings_provider::USE_SEB_CLIENT_CONFIG); $url = 'https://www.example.com/moodle'; $settings = seb_quiz_settings::get_record(['quizid' => $this->quiz->id]); $browserexamkey = hash('sha256', 'browserexamkey'); $fullbrowserexamkey = hash('sha256', $url . $browserexamkey); $settings->set('allowedbrowserexamkeys', $browserexamkey); // Add a hashed BEK. $settings->save(); $accessmanager = $this->get_access_manager(); $this->assertTrue($accessmanager->validate_browser_exam_key($fullbrowserexamkey, $url)); } /** * Test can get received config key. */ public function test_get_received_config_key(): void { $this->quiz = $this->create_test_quiz($this->course, settings_provider::USE_SEB_CLIENT_CONFIG); $accessmanager = $this->get_access_manager(); $this->assertNull($accessmanager->get_received_config_key()); $_SERVER['HTTP_X_SAFEEXAMBROWSER_CONFIGKEYHASH'] = 'Test key'; $this->assertEquals('Test key', $accessmanager->get_received_config_key()); } /** * Test can get received browser key. */ public function get_received_browser_exam_key() { $this->quiz = $this->create_test_quiz($this->course, settings_provider::USE_SEB_CLIENT_CONFIG); $accessmanager = $this->get_access_manager(); $this->assertNull($accessmanager->get_received_browser_exam_key()); $_SERVER['HTTP_X_SAFEEXAMBROWSER_REQUESTHASH'] = 'Test browser key'; $this->assertEquals('Test browser key', $accessmanager->get_received_browser_exam_key()); } /** * Test can correctly get type of SEB usage for the quiz. */ public function test_get_seb_use_type(): void { // No SEB. $this->quiz = $this->create_test_quiz($this->course); $accessmanager = $this->get_access_manager(); $this->assertEquals(settings_provider::USE_SEB_NO, $accessmanager->get_seb_use_type()); // Manually. $this->quiz = $this->create_test_quiz($this->course, settings_provider::USE_SEB_CONFIG_MANUALLY); $accessmanager = $this->get_access_manager(); $this->assertEquals(settings_provider::USE_SEB_CONFIG_MANUALLY, $accessmanager->get_seb_use_type()); // Use template. $this->quiz = $this->create_test_quiz($this->course, settings_provider::USE_SEB_CONFIG_MANUALLY); $quizsettings = seb_quiz_settings::get_record(['quizid' => $this->quiz->id]); $quizsettings->set('requiresafeexambrowser', settings_provider::USE_SEB_TEMPLATE); $quizsettings->set('templateid', $this->create_template()->get('id')); $quizsettings->save(); $accessmanager = $this->get_access_manager(); $this->assertEquals(settings_provider::USE_SEB_TEMPLATE, $accessmanager->get_seb_use_type()); // Use uploaded config. $this->quiz = $this->create_test_quiz($this->course, settings_provider::USE_SEB_CONFIG_MANUALLY); $quizsettings = seb_quiz_settings::get_record(['quizid' => $this->quiz->id]); $quizsettings->set('requiresafeexambrowser', settings_provider::USE_SEB_UPLOAD_CONFIG); // Doesn't check basic header. $xml = file_get_contents(self::get_fixture_path(__NAMESPACE__, 'unencrypted.seb')); $this->create_module_test_file($xml, $this->quiz->cmid); $quizsettings->save(); $accessmanager = $this->get_access_manager(); $this->assertEquals(settings_provider::USE_SEB_UPLOAD_CONFIG, $accessmanager->get_seb_use_type()); // Use client config. $this->quiz = $this->create_test_quiz($this->course, settings_provider::USE_SEB_CLIENT_CONFIG); $accessmanager = $this->get_access_manager(); $this->assertEquals(settings_provider::USE_SEB_CLIENT_CONFIG, $accessmanager->get_seb_use_type()); } /** * Data provider for self::test_should_validate_basic_header. * * @return array */ public static function should_validate_basic_header_data_provider(): array { return [ [settings_provider::USE_SEB_NO, false], [settings_provider::USE_SEB_CONFIG_MANUALLY, false], [settings_provider::USE_SEB_TEMPLATE, false], [settings_provider::USE_SEB_UPLOAD_CONFIG, false], [settings_provider::USE_SEB_CLIENT_CONFIG, true], ]; } /** * Test we know when we should validate basic header. * * @param int $type Type of SEB usage. * @param bool $expected Expected result. * * @dataProvider should_validate_basic_header_data_provider */ public function test_should_validate_basic_header($type, $expected): void { $accessmanager = $this->getMockBuilder(seb_access_manager::class) ->disableOriginalConstructor() ->onlyMethods(['get_seb_use_type']) ->getMock(); $accessmanager->method('get_seb_use_type')->willReturn($type); $this->assertEquals($expected, $accessmanager->should_validate_basic_header()); } /** * Data provider for self::test_should_validate_config_key. * * @return array */ public static function should_validate_config_key_data_provider(): array { return [ [settings_provider::USE_SEB_NO, false], [settings_provider::USE_SEB_CONFIG_MANUALLY, true], [settings_provider::USE_SEB_TEMPLATE, true], [settings_provider::USE_SEB_UPLOAD_CONFIG, true], [settings_provider::USE_SEB_CLIENT_CONFIG, false], ]; } /** * Test we know when we should validate config key. * * @param int $type Type of SEB usage. * @param bool $expected Expected result. * * @dataProvider should_validate_config_key_data_provider */ public function test_should_validate_config_key($type, $expected): void { $accessmanager = $this->getMockBuilder(seb_access_manager::class) ->disableOriginalConstructor() ->onlyMethods(['get_seb_use_type']) ->getMock(); $accessmanager->method('get_seb_use_type')->willReturn($type); $this->assertEquals($expected, $accessmanager->should_validate_config_key()); } /** * Data provider for self::test_should_validate_browser_exam_key. * * @return array */ public static function should_validate_browser_exam_key_data_provider(): array { return [ [settings_provider::USE_SEB_NO, false], [settings_provider::USE_SEB_CONFIG_MANUALLY, false], [settings_provider::USE_SEB_TEMPLATE, false], [settings_provider::USE_SEB_UPLOAD_CONFIG, true], [settings_provider::USE_SEB_CLIENT_CONFIG, true], ]; } /** * Test we know when we should browser exam key. * * @param int $type Type of SEB usage. * @param bool $expected Expected result. * * @dataProvider should_validate_browser_exam_key_data_provider */ public function test_should_validate_browser_exam_key($type, $expected): void { $accessmanager = $this->getMockBuilder(seb_access_manager::class) ->disableOriginalConstructor() ->onlyMethods(['get_seb_use_type']) ->getMock(); $accessmanager->method('get_seb_use_type')->willReturn($type); $this->assertEquals($expected, $accessmanager->should_validate_browser_exam_key()); } /** * Test that access manager uses cached Config Key. */ public function test_access_manager_uses_cached_config_key(): void { global $FULLME; $this->quiz = $this->create_test_quiz($this->course, settings_provider::USE_SEB_CONFIG_MANUALLY); $accessmanager = $this->get_access_manager(); $configkey = $accessmanager->get_valid_config_key(); // Set up dummy request. $FULLME = 'https://example.com/moodle/mod/quiz/attempt.php?attemptid=123&page=4'; $expectedhash = hash('sha256', $FULLME . $configkey); $_SERVER['HTTP_X_SAFEEXAMBROWSER_CONFIGKEYHASH'] = $expectedhash; $this->assertTrue($accessmanager->validate_config_key()); // Change settings (but don't save) and check that still can validate config key. $quizsettings = seb_quiz_settings::get_record(['quizid' => $this->quiz->id]); $quizsettings->set('showsebtaskbar', 0); $this->assertNotEquals($quizsettings->get_config_key(), $configkey); $this->assertTrue($accessmanager->validate_config_key()); // Now save settings which should purge caches but access manager still has config key. $quizsettings->save(); $this->assertNotEquals($quizsettings->get_config_key(), $configkey); $this->assertTrue($accessmanager->validate_config_key()); // Initialise a new access manager. Now validation should fail. $accessmanager = $this->get_access_manager(); $this->assertFalse($accessmanager->validate_config_key()); } /** * Check that valid SEB config key is null if quiz doesn't have SEB settings. */ public function test_valid_config_key_is_null_if_no_settings(): void { $this->quiz = $this->create_test_quiz($this->course, settings_provider::USE_SEB_NO); $accessmanager = $this->get_access_manager(); $this->assertEmpty(seb_quiz_settings::get_record(['quizid' => $this->quiz->id])); $this->assertNull($accessmanager->get_valid_config_key()); } /** * Test if config key should not be validated. */ public function test_if_config_key_should_not_be_validated(): void { $this->quiz = $this->create_test_quiz($this->course, settings_provider::USE_SEB_NO); $accessmanager = $this->get_access_manager(); $this->assertTrue($accessmanager->validate_config_key()); } /** * Test if browser exam key should not be validated. */ public function test_if_browser_exam_key_should_not_be_validated(): void { $this->quiz = $this->create_test_quiz($this->course, settings_provider::USE_SEB_CONFIG_MANUALLY); $accessmanager = $this->get_access_manager(); $this->assertTrue($accessmanager->validate_browser_exam_key()); } /** * Test that access is set correctly in Moodle session. */ public function test_set_session_access(): void { global $SESSION; $this->quiz = $this->create_test_quiz($this->course, settings_provider::USE_SEB_CLIENT_CONFIG); $accessmanager = $this->get_access_manager(); $this->assertTrue(empty($SESSION->quizaccess_seb_access[$this->quiz->cmid])); $accessmanager->set_session_access(true); $this->assertTrue($SESSION->quizaccess_seb_access[$this->quiz->cmid]); } /** * Test that access is set in Moodle session for only course module associated with access manager. */ public function test_session_access_set_for_specific_course_module(): void { global $SESSION; $this->quiz = $this->create_test_quiz($this->course, settings_provider::USE_SEB_CLIENT_CONFIG); $quiz2 = $this->create_test_quiz($this->course, settings_provider::USE_SEB_CLIENT_CONFIG); $accessmanager = $this->get_access_manager(); $accessmanager->set_session_access(true); $this->assertCount(1, $SESSION->quizaccess_seb_access); $this->assertTrue($SESSION->quizaccess_seb_access[$this->quiz->cmid]); $this->assertTrue(empty($SESSION->quizaccess_seb_access[$quiz2->cmid])); } /** * Test that access state can be retrieved from Moodle session. */ public function test_validate_session_access(): void { global $SESSION; $this->quiz = $this->create_test_quiz($this->course, settings_provider::USE_SEB_CLIENT_CONFIG); $accessmanager = $this->get_access_manager(); $this->assertEmpty($accessmanager->validate_session_access()); $SESSION->quizaccess_seb_access[$this->quiz->cmid] = true; $this->assertTrue($accessmanager->validate_session_access()); } /** * Test that access can be cleared from Moodle session. */ public function test_clear_session_access(): void { global $SESSION; $this->quiz = $this->create_test_quiz($this->course, settings_provider::USE_SEB_CLIENT_CONFIG); $accessmanager = $this->get_access_manager(); $SESSION->quizaccess_seb_access[$this->quiz->cmid] = true; $accessmanager->clear_session_access(); $this->assertTrue(empty($SESSION->quizaccess_seb_access[$this->quiz->cmid])); } /** * Test we can decide if need to redirect to SEB config link. */ public function test_should_redirect_to_seb_config_link(): void { $this->quiz = $this->create_test_quiz($this->course, settings_provider::USE_SEB_CONFIG_MANUALLY); $accessmanager = $this->get_access_manager(); set_config('autoreconfigureseb', '1', 'quizaccess_seb'); $_SERVER['HTTP_USER_AGENT'] = 'SEB'; $this->assertFalse($accessmanager->should_redirect_to_seb_config_link()); set_config('autoreconfigureseb', '1', 'quizaccess_seb'); $_SERVER['HTTP_USER_AGENT'] = 'SEB'; $_SERVER['HTTP_X_SAFEEXAMBROWSER_CONFIGKEYHASH'] = hash('sha256', 'configkey'); $this->assertTrue($accessmanager->should_redirect_to_seb_config_link()); } } PK9B\$$&accessrule/seb/tests/template_test.phpnu[. namespace quizaccess_seb; defined('MOODLE_INTERNAL') || die(); require_once(__DIR__ . '/test_helper_trait.php'); /** * PHPUnit tests for template class. * * @package quizaccess_seb * @author Dmitrii Metelkin * @copyright 2020 Catalyst IT * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class template_test extends \advanced_testcase { use \quizaccess_seb_test_helper_trait; /** * Called before every test. */ public function setUp(): void { parent::setUp(); $this->resetAfterTest(); } /** * Test that template saved with valid content. */ public function test_template_is_saved(): void { global $DB; $data = new \stdClass(); $data->name = 'Test name'; $data->description = 'Test description'; $data->enabled = 1; $data->content = " showTaskBarallowWlanshowReloadButton" . "showTimeshowInputLanguageallowQuit" . "quitURLConfirmaudioControlEnabledaudioMute" . "browserMediaCaptureCamerabrowserMediaCaptureMicrophone" . "allowSpellCheckbrowserWindowAllowReloadURLFilterEnable" . "URLFilterEnableContentFilterhashedQuitPassword" . "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08URLFilterRules" . "action1activeexpression" . "test.comregex" . "sendBrowserExamKey\n"; $template = new template(0, $data); $template->save(); $actual = $DB->get_record(template::TABLE, ['id' => $template->get('id')]); $this->assertEquals($data->name, $actual->name); $this->assertEquals($data->description, $actual->description); $this->assertEquals($data->enabled, $actual->enabled); $this->assertEquals($data->content, $actual->content); $this->assertTrue($template->can_delete()); } /** * Test that template is not saved with invalid content. */ public function test_template_is_not_saved_with_invalid_content(): void { $this->expectException(\core\invalid_persistent_exception::class); $this->expectExceptionMessage('Invalid SEB config template'); $data = new \stdClass(); $data->name = 'Test name'; $data->description = 'Test description'; $data->enabled = 1; $data->content = "Invalid content"; $template = new template(0, $data); $template->save(); } /** * Test that a template cannot be deleted when assigned to a quiz. */ public function test_cannot_delete_template_when_assigned_to_quiz(): void { global $DB; $data = new \stdClass(); $data->name = 'Test name'; $data->description = 'Test description'; $data->enabled = 1; $data->content = " showTaskBarallowWlanshowReloadButton" . "showTimeshowInputLanguageallowQuit" . "quitURLConfirmaudioControlEnabledaudioMute" . "browserMediaCaptureCamerabrowserMediaCaptureMicrophone" . "allowSpellCheckbrowserWindowAllowReloadURLFilterEnable" . "URLFilterEnableContentFilterhashedQuitPassword" . "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08URLFilterRules" . "action1activeexpression" . "test.comregex" . "sendBrowserExamKey\n"; $template = new template(0, $data); $template->save(); $this->assertTrue($template->can_delete()); $DB->insert_record(seb_quiz_settings::TABLE, (object) [ 'quizid' => 1, 'cmid' => 1, 'templateid' => $template->get('id'), 'requiresafeexambrowser' => '1', 'sebconfigfile' => '373552893', 'showsebtaskbar' => '1', 'showwificontrol' => '0', 'showreloadbutton' => '1', 'showtime' => '0', 'showkeyboardlayout' => '1', 'allowuserquitseb' => '1', 'quitpassword' => 'test', 'linkquitseb' => '', 'userconfirmquit' => '1', 'enableaudiocontrol' => '1', 'allowcapturecamera' => '1', 'allowcapturemicrophone' => '1', 'muteonstartup' => '0', 'allowspellchecking' => '0', 'allowreloadinexam' => '1', 'activateurlfiltering' => '1', 'filterembeddedcontent' => '0', 'expressionsallowed' => 'test.com', 'regexallowed' => '', 'expressionsblocked' => '', 'regexblocked' => '', 'showsebdownloadlink' => '1', 'config' => '', ]); $this->assertFalse($template->can_delete()); } /** * Test that a disabled template no longer shows up in quiz SEB settings other than quizzes already using it. * * @covers \quizaccess_seb\seb_quiz_settings::get_record * @covers \quizaccess_seb\settings_provider::get_requiresafeexambrowser_options */ public function test_disabled_template_quiz_setting_options(): void { // Create quiz and fetch standard SEB requirement options. $this->setAdminUser(); $this->course = $this->getDataGenerator()->create_course(); $templateoptionstr = get_string('seb_use_template', 'quizaccess_seb'); // Create a quiz. $this->quiz = $this->create_test_quiz($this->course, settings_provider::USE_SEB_CONFIG_MANUALLY); $context = \context_module::instance($this->quiz->cmid); // Check there is no template option (as there aren't any). $options = settings_provider::get_requiresafeexambrowser_options($context); $this->assertNotContainsEquals($templateoptionstr, $options); // Create a template. $data = new \stdClass(); $data->name = 'Test name'; $data->description = 'Test description'; $data->enabled = 1; $data->content = file_get_contents(self::get_fixture_path(__NAMESPACE__, 'unencrypted.seb')); $template = new template(0, $data); $template->save(); // Check options now include template option. $options = settings_provider::get_requiresafeexambrowser_options($context); $this->assertContainsEquals($templateoptionstr, $options); // Set SEB setting to use template for quiz. $settings = seb_quiz_settings::get_record(['quizid' => $this->quiz->id]); $settings->set('templateid', $template->get('id')); $settings->set('requiresafeexambrowser', settings_provider::USE_SEB_TEMPLATE); $settings->save(); // Disable template. $template->set('enabled', 0); $template->save(); // Check option still exists on current quiz. $options = settings_provider::get_requiresafeexambrowser_options($context); $this->assertContainsEquals($templateoptionstr, $options); // Create a new quiz. $newquiz = $this->create_test_quiz($this->course, settings_provider::USE_SEB_CONFIG_MANUALLY); $context = \context_module::instance($newquiz->cmid); // Check there is no template option (as the template is now disabled). $options = settings_provider::get_requiresafeexambrowser_options($context); $this->assertNotContainsEquals($templateoptionstr, $options); } } PK9B\>>+accessrule/seb/tests/quiz_settings_test.phpnu[. namespace quizaccess_seb; use context_module; use moodle_url; defined('MOODLE_INTERNAL') || die(); require_once(__DIR__ . '/test_helper_trait.php'); /** * PHPUnit tests for seb_quiz_settings class. * * @package quizaccess_seb * @author Andrew Madden * @copyright 2020 Catalyst IT * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class quiz_settings_test extends \advanced_testcase { use \quizaccess_seb_test_helper_trait; /** @var context_module $context Test context. */ protected $context; /** @var moodle_url $url Test quiz URL. */ protected $url; /** * Called before every test. */ public function setUp(): void { parent::setUp(); $this->resetAfterTest(); $this->setAdminUser(); $this->course = $this->getDataGenerator()->create_course(); $this->quiz = $this->getDataGenerator()->create_module('quiz', [ 'course' => $this->course->id, 'seb_requiresafeexambrowser' => settings_provider::USE_SEB_CONFIG_MANUALLY, ]); $this->context = \context_module::instance($this->quiz->cmid); $this->url = new \moodle_url("/mod/quiz/view.php", ['id' => $this->quiz->cmid]); } /** * Test that config is generated immediately prior to saving quiz settings. */ public function test_config_is_created_from_quiz_settings(): void { // Test settings to populate the in the object. $settings = $this->get_test_settings([ 'quizid' => $this->quiz->id, 'cmid' => $this->quiz->cmid, ]); // Obtain the existing record that is created when using a generator. $quizsettings = seb_quiz_settings::get_record(['quizid' => $this->quiz->id]); // Update the settings with values from the test function. $quizsettings->from_record($settings); $quizsettings->save(); $config = $quizsettings->get_config(); $this->assertEquals( " showTaskBarallowWlanshowReloadButton" . "showTimeshowInputLanguageallowQuit" . "quitURLConfirmaudioControlEnabledaudioMute" . "browserMediaCaptureCamerabrowserMediaCaptureMicrophone" . "allowSpellCheckbrowserWindowAllowReloadURLFilterEnable" . "URLFilterEnableContentFilterhashedQuitPassword" . "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08URLFilterRules" . "action1activeexpression" . "test.comregexstartURL$this->url" . "sendBrowserExamKeybrowserWindowWebView3" . "examSessionClearCookiesOnStartallowPreferencesWindow\n", $config); } /** * Test that config string gets updated from quiz settings. */ public function test_config_is_updated_from_quiz_settings(): void { // Test settings to populate the in the object. $settings = $this->get_test_settings([ 'quizid' => $this->quiz->id, 'cmid' => $this->quiz->cmid, ]); // Obtain the existing record that is created when using a generator. $quizsettings = seb_quiz_settings::get_record(['quizid' => $this->quiz->id]); // Update the settings with values from the test function. $quizsettings->from_record($settings); $quizsettings->save(); $config = $quizsettings->get_config(); $this->assertEquals(" showTaskBarallowWlanshowReloadButton" . "showTimeshowInputLanguageallowQuit" . "quitURLConfirmaudioControlEnabledaudioMute" . "browserMediaCaptureCamerabrowserMediaCaptureMicrophone" . "allowSpellCheckbrowserWindowAllowReloadURLFilterEnable" . "URLFilterEnableContentFilterhashedQuitPassword" . "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08URLFilterRules" . "action1activeexpression" . "test.comregexstartURL$this->url" . "sendBrowserExamKeybrowserWindowWebView3" . "examSessionClearCookiesOnStart" . "allowPreferencesWindow\n", $config); $quizsettings->set('filterembeddedcontent', 1); // Alter the settings. $quizsettings->save(); $config = $quizsettings->get_config(); $this->assertEquals(" showTaskBarallowWlanshowReloadButton" . "showTimeshowInputLanguageallowQuit" . "quitURLConfirmaudioControlEnabledaudioMute" . "browserMediaCaptureCamerabrowserMediaCaptureMicrophone" . "allowSpellCheckbrowserWindowAllowReloadURLFilterEnable" . "URLFilterEnableContentFilterhashedQuitPassword" . "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08URLFilterRules" . "action1activeexpression" . "test.comregexstartURL$this->url" . "sendBrowserExamKeybrowserWindowWebView3" . "examSessionClearCookiesOnStart" . "allowPreferencesWindow\n", $config); } /** * Test that config key is generated immediately prior to saving quiz settings. */ public function test_config_key_is_created_from_quiz_settings(): void { $settings = $this->get_test_settings(); $quizsettings = new seb_quiz_settings(0, $settings); $configkey = $quizsettings->get_config_key(); $this->assertEquals("24712dcecc69a2ca8ad19f4f8928375038f087e13ce9f504cfc4496846aaaf27", $configkey ); } /** * Test that config key is generated immediately prior to saving quiz settings. */ public function test_config_key_is_updated_from_quiz_settings(): void { $settings = $this->get_test_settings(); $quizsettings = new seb_quiz_settings(0, $settings); $configkey = $quizsettings->get_config_key(); $this->assertEquals("24712dcecc69a2ca8ad19f4f8928375038f087e13ce9f504cfc4496846aaaf27", $configkey); $quizsettings->set('filterembeddedcontent', 1); // Alter the settings. $configkey = $quizsettings->get_config_key(); $this->assertEquals("e2f6dc2a6ddb9bdfa7530dc8afbae996499c34a954e1c635c4fed894e47c1abc", $configkey); } /** * Test that different URL filter expressions are turned into config XML. * * @param \stdClass $settings Quiz settings * @param string $expectedxml SEB Config XML. * * @dataProvider filter_rules_provider */ public function test_filter_rules_added_to_config(\stdClass $settings, string $expectedxml): void { $quizsettings = new seb_quiz_settings(0, $settings); $config = $quizsettings->get_config(); $this->assertEquals($expectedxml, $config); } /** * Test that browser keys are validated and retrieved as an array instead of string. */ public function test_browser_exam_keys_are_retrieved_as_array(): void { $quizsettings = new seb_quiz_settings(); $quizsettings->set('allowedbrowserexamkeys', "one two,three\nfour"); $retrievedkeys = $quizsettings->get('allowedbrowserexamkeys'); $this->assertEquals(['one', 'two', 'three', 'four'], $retrievedkeys); } /** * Test validation of Browser Exam Keys. * * @param string $bek Browser Exam Key. * @param string $expectederrorstring Expected error. * * @dataProvider bad_browser_exam_key_provider */ public function test_browser_exam_keys_validation_errors($bek, $expectederrorstring): void { $quizsettings = new seb_quiz_settings(); $quizsettings->set('allowedbrowserexamkeys', $bek); $quizsettings->validate(); $errors = $quizsettings->get_errors(); $this->assertContainsEquals($expectederrorstring, $errors); } /** * Test that uploaded seb file gets converted to config string. */ public function test_config_file_uploaded_converted_to_config(): void { $url = new \moodle_url("/mod/quiz/view.php", ['id' => $this->quiz->cmid]); $xml = "\n" . "\n" . "hashedQuitPasswordhashedpassword" . "allowWlanstartURL$url" . "sendBrowserExamKeybrowserWindowWebView3\n"; $itemid = $this->create_module_test_file($xml, $this->quiz->cmid); $quizsettings = seb_quiz_settings::get_record(['quizid' => $this->quiz->id]); $quizsettings->set('requiresafeexambrowser', settings_provider::USE_SEB_UPLOAD_CONFIG); $quizsettings->save(); $config = $quizsettings->get_config(); $this->assertEquals($xml, $config); } /** * Test test_no_config_file_uploaded */ public function test_no_config_file_uploaded(): void { $quizsettings = seb_quiz_settings::get_record(['quizid' => $this->quiz->id]); $quizsettings->set('requiresafeexambrowser', settings_provider::USE_SEB_UPLOAD_CONFIG); $cmid = $quizsettings->get('cmid'); $this->expectException(\moodle_exception::class); $this->expectExceptionMessage("No uploaded SEB config file could be found for quiz with cmid: {$cmid}"); $quizsettings->get_config(); } /** * A helper function to build a config file. * * @param mixed $allowuserquitseb Required allowQuit setting. * @param mixed $quitpassword Required hashedQuitPassword setting. * * @return string */ protected function get_config_xml($allowuserquitseb = null, $quitpassword = null) { $xml = "\n" . "\n" . "allowWlanstartURL" . "https://safeexambrowser.org/start" . "sendBrowserExamKey"; if (!is_null($allowuserquitseb)) { $allowuserquitseb = empty($allowuserquitseb) ? 'false' : 'true'; $xml .= "allowQuit<{$allowuserquitseb}/>"; } if (!is_null($quitpassword)) { $xml .= "hashedQuitPassword{$quitpassword}"; } $xml .= "\n"; return $xml; } /** * Test using USE_SEB_TEMPLATE and have it override settings from the template when they are set. */ public function test_using_seb_template_override_settings_when_they_set_in_template(): void { $xml = $this->get_config_xml(true, 'password'); $template = $this->create_template($xml); $this->assertStringContainsString("startURLhttps://safeexambrowser.org/start", $template->get('content')); $this->assertStringContainsString("allowQuit", $template->get('content')); $this->assertStringContainsString("hashedQuitPasswordpassword", $template->get('content')); $quizsettings = seb_quiz_settings::get_record(['quizid' => $this->quiz->id]); $quizsettings->set('requiresafeexambrowser', settings_provider::USE_SEB_TEMPLATE); $quizsettings->set('templateid', $template->get('id')); $quizsettings->set('allowuserquitseb', 1); $quizsettings->save(); $this->assertStringContainsString( "startURLhttps://www.example.com/moodle/mod/quiz/view.php?id={$this->quiz->cmid}", $quizsettings->get_config() ); $this->assertStringContainsString("allowQuit", $quizsettings->get_config()); $this->assertStringNotContainsString("hashedQuitPassword", $quizsettings->get_config()); $quizsettings->set('quitpassword', 'new password'); $quizsettings->save(); $hashedpassword = hash('SHA256', 'new password'); $this->assertStringContainsString("allowQuit", $quizsettings->get_config()); $this->assertStringNotContainsString("hashedQuitPasswordpassword", $quizsettings->get_config()); $this->assertStringContainsString("hashedQuitPassword{$hashedpassword}", $quizsettings->get_config()); $quizsettings->set('allowuserquitseb', 0); $quizsettings->set('quitpassword', ''); $quizsettings->save(); $this->assertStringContainsString("allowQuit", $quizsettings->get_config()); $this->assertStringNotContainsString("hashedQuitPassword", $quizsettings->get_config()); } /** * Test using USE_SEB_TEMPLATE and have it override settings from the template when they are not set. */ public function test_using_seb_template_override_settings_when_not_set_in_template(): void { $xml = $this->get_config_xml(); $template = $this->create_template($xml); $this->assertStringContainsString("startURLhttps://safeexambrowser.org/start", $template->get('content')); $this->assertStringNotContainsString("allowQuit", $template->get('content')); $this->assertStringNotContainsString("hashedQuitPasswordpassword", $template->get('content')); $quizsettings = seb_quiz_settings::get_record(['quizid' => $this->quiz->id]); $quizsettings->set('requiresafeexambrowser', settings_provider::USE_SEB_TEMPLATE); $quizsettings->set('templateid', $template->get('id')); $quizsettings->set('allowuserquitseb', 1); $quizsettings->save(); $this->assertStringContainsString("allowQuit", $quizsettings->get_config()); $this->assertStringNotContainsString("hashedQuitPassword", $quizsettings->get_config()); $quizsettings->set('quitpassword', 'new password'); $quizsettings->save(); $hashedpassword = hash('SHA256', 'new password'); $this->assertStringContainsString("allowQuit", $quizsettings->get_config()); $this->assertStringContainsString("hashedQuitPassword{$hashedpassword}", $quizsettings->get_config()); $quizsettings->set('allowuserquitseb', 0); $quizsettings->set('quitpassword', ''); $quizsettings->save(); $this->assertStringContainsString("allowQuit", $quizsettings->get_config()); $this->assertStringNotContainsString("hashedQuitPassword", $quizsettings->get_config()); } /** * Test using USE_SEB_UPLOAD_CONFIG and use settings from the file if they are set. */ public function test_using_own_config_settings_are_not_overridden_if_set(): void { $xml = $this->get_config_xml(true, 'password'); $this->create_module_test_file($xml, $this->quiz->cmid); $quizsettings = seb_quiz_settings::get_record(['quizid' => $this->quiz->id]); $quizsettings->set('requiresafeexambrowser', settings_provider::USE_SEB_UPLOAD_CONFIG); $quizsettings->set('allowuserquitseb', 0); $quizsettings->set('quitpassword', ''); $quizsettings->save(); $this->assertStringContainsString( "startURLhttps://www.example.com/moodle/mod/quiz/view.php?id={$this->quiz->cmid}", $quizsettings->get_config() ); $this->assertStringContainsString("allowQuit", $quizsettings->get_config()); $this->assertStringContainsString("hashedQuitPasswordpassword", $quizsettings->get_config()); $quizsettings->set('quitpassword', 'new password'); $quizsettings->save(); $hashedpassword = hash('SHA256', 'new password'); $this->assertStringNotContainsString("hashedQuitPassword{$hashedpassword}", $quizsettings->get_config()); $this->assertStringContainsString("allowQuit", $quizsettings->get_config()); $this->assertStringContainsString("hashedQuitPasswordpassword", $quizsettings->get_config()); $quizsettings->set('allowuserquitseb', 0); $quizsettings->set('quitpassword', ''); $quizsettings->save(); $this->assertStringContainsString("allowQuit", $quizsettings->get_config()); $this->assertStringContainsString("hashedQuitPasswordpassword", $quizsettings->get_config()); } /** * Test using USE_SEB_UPLOAD_CONFIG and use settings from the file if they are not set. */ public function test_using_own_config_settings_are_not_overridden_if_not_set(): void { $xml = $this->get_config_xml(); $this->create_module_test_file($xml, $this->quiz->cmid); $quizsettings = seb_quiz_settings::get_record(['quizid' => $this->quiz->id]); $quizsettings->set('requiresafeexambrowser', settings_provider::USE_SEB_UPLOAD_CONFIG); $quizsettings->set('allowuserquitseb', 1); $quizsettings->set('quitpassword', ''); $quizsettings->save(); $this->assertStringContainsString( "startURLhttps://www.example.com/moodle/mod/quiz/view.php?id={$this->quiz->cmid}", $quizsettings->get_config() ); $this->assertStringNotContainsString("allowQuit", $quizsettings->get_config()); $this->assertStringNotContainsString("hashedQuitPassword", $quizsettings->get_config()); $quizsettings->set('quitpassword', 'new password'); $quizsettings->save(); $this->assertStringNotContainsString("allowQuit", $quizsettings->get_config()); $this->assertStringNotContainsString("hashedQuitPassword", $quizsettings->get_config()); $quizsettings->set('allowuserquitseb', 0); $quizsettings->set('quitpassword', ''); $quizsettings->save(); $this->assertStringNotContainsString("allowQuit", $quizsettings->get_config()); $this->assertStringNotContainsString("hashedQuitPassword", $quizsettings->get_config()); } /** * Test using USE_SEB_TEMPLATE populates the linkquitseb setting if a quitURL is found. */ public function test_template_has_quit_url_set(): void { $xml = "\n" . "\n" . "hashedQuitPasswordhashedpassword" . "allowWlanquitURLhttp://seb.quit.url" . "sendBrowserExamKey\n"; $template = $this->create_template($xml); $quizsettings = seb_quiz_settings::get_record(['quizid' => $this->quiz->id]); $quizsettings->set('requiresafeexambrowser', settings_provider::USE_SEB_TEMPLATE); $quizsettings->set('templateid', $template->get('id')); $this->assertEmpty($quizsettings->get('linkquitseb')); $quizsettings->save(); $this->assertNotEmpty($quizsettings->get('linkquitseb')); $this->assertEquals('http://seb.quit.url', $quizsettings->get('linkquitseb')); } /** * Test using USE_SEB_UPLOAD_CONFIG populates the linkquitseb setting if a quitURL is found. */ public function test_config_file_uploaded_has_quit_url_set(): void { $xml = "\n" . "\n" . "hashedQuitPasswordhashedpassword" . "allowWlanquitURLhttp://seb.quit.url" . "sendBrowserExamKey\n"; $itemid = $this->create_module_test_file($xml, $this->quiz->cmid); $quizsettings = seb_quiz_settings::get_record(['quizid' => $this->quiz->id]); $quizsettings->set('requiresafeexambrowser', settings_provider::USE_SEB_UPLOAD_CONFIG); $this->assertEmpty($quizsettings->get('linkquitseb')); $quizsettings->save(); $this->assertNotEmpty($quizsettings->get('linkquitseb')); $this->assertEquals('http://seb.quit.url', $quizsettings->get('linkquitseb')); } /** * Test template id set correctly. */ public function test_templateid_set_correctly_when_save_settings(): void { $quizsettings = seb_quiz_settings::get_record(['quizid' => $this->quiz->id]); $this->assertEquals(0, $quizsettings->get('templateid')); $template = $this->create_template(); $templateid = $template->get('id'); // Initially set to USE_SEB_TEMPLATE with a template id. $this->save_settings_with_optional_template($quizsettings, settings_provider::USE_SEB_TEMPLATE, $templateid); $quizsettings = seb_quiz_settings::get_record(['quizid' => $this->quiz->id]); $this->assertEquals($templateid, $quizsettings->get('templateid')); // Case for USE_SEB_NO, ensure template id reverts to 0. $this->save_settings_with_optional_template($quizsettings, settings_provider::USE_SEB_NO); $quizsettings = seb_quiz_settings::get_record(['quizid' => $this->quiz->id]); $this->assertEquals(0, $quizsettings->get('templateid')); // Reverting back to USE_SEB_TEMPLATE. $this->save_settings_with_optional_template($quizsettings, settings_provider::USE_SEB_TEMPLATE, $templateid); // Case for USE_SEB_CONFIG_MANUALLY, ensure template id reverts to 0. $this->save_settings_with_optional_template($quizsettings, settings_provider::USE_SEB_CONFIG_MANUALLY); $quizsettings = seb_quiz_settings::get_record(['quizid' => $this->quiz->id]); $this->assertEquals(0, $quizsettings->get('templateid')); // Reverting back to USE_SEB_TEMPLATE. $this->save_settings_with_optional_template($quizsettings, settings_provider::USE_SEB_TEMPLATE, $templateid); // Case for USE_SEB_CLIENT_CONFIG, ensure template id reverts to 0. $this->save_settings_with_optional_template($quizsettings, settings_provider::USE_SEB_CLIENT_CONFIG); $quizsettings = seb_quiz_settings::get_record(['quizid' => $this->quiz->id]); $this->assertEquals(0, $quizsettings->get('templateid')); // Reverting back to USE_SEB_TEMPLATE. $this->save_settings_with_optional_template($quizsettings, settings_provider::USE_SEB_TEMPLATE, $templateid); // Case for USE_SEB_UPLOAD_CONFIG, ensure template id reverts to 0. $xml = file_get_contents(self::get_fixture_path(__NAMESPACE__, 'unencrypted.seb')); $this->create_module_test_file($xml, $this->quiz->cmid); $this->save_settings_with_optional_template($quizsettings, settings_provider::USE_SEB_UPLOAD_CONFIG); $quizsettings = seb_quiz_settings::get_record(['quizid' => $this->quiz->id]); $this->assertEquals(0, $quizsettings->get('templateid')); // Case for USE_SEB_TEMPLATE, ensure template id is correct. $this->save_settings_with_optional_template($quizsettings, settings_provider::USE_SEB_TEMPLATE, $templateid); $quizsettings = seb_quiz_settings::get_record(['quizid' => $this->quiz->id]); $this->assertEquals($templateid, $quizsettings->get('templateid')); } /** * Helper function in tests to set USE_SEB_TEMPLATE and a template id on the quiz settings. * * @param seb_quiz_settings $quizsettings Given quiz settings instance. * @param int $savetype Type of SEB usage. * @param int $templateid Template ID. */ public function save_settings_with_optional_template($quizsettings, $savetype, $templateid = 0) { $quizsettings->set('requiresafeexambrowser', $savetype); if (!empty($templateid)) { $quizsettings->set('templateid', $templateid); } $quizsettings->save(); } /** * Bad browser exam key data provider. * * @return array */ public static function bad_browser_exam_key_provider(): array { return [ 'Short string' => ['fdsf434r', 'A key should be a 64-character hex string.'], 'Non hex string' => ['aadf6799aadf6789aadf6789aadf6789aadf6789aadf6789aadf6789aadf678!', 'A key should be a 64-character hex string.'], 'Non unique' => ["aadf6799aadf6789aadf6789aadf6789aadf6789aadf6789aadf6789aadf6789" . "\naadf6799aadf6789aadf6789aadf6789aadf6789aadf6789aadf6789aadf6789", 'The keys must all be different.'], ]; } /** * Provide settings for different filter rules. * * @return array Test data. */ public static function filter_rules_provider(): array { return [ 'enabled simple expessions' => [ (object) [ 'requiresafeexambrowser' => settings_provider::USE_SEB_CONFIG_MANUALLY, 'quizid' => 1, 'cmid' => 1, 'expressionsallowed' => "test.com\r\nsecond.hello", 'regexallowed' => '', 'expressionsblocked' => '', 'regexblocked' => '', ], "\n" . "\n" . "showTaskBar" . "allowWlanshowReloadButton" . "showTimeshowInputLanguageallowQuit" . "quitURLConfirmaudioControlEnabledaudioMute" . "browserMediaCaptureCamerabrowserMediaCaptureMicrophone" . "allowSpellCheckbrowserWindowAllowReloadURLFilterEnable" . "URLFilterEnableContentFilterURLFilterRules" . "action1active" . "expressiontest.com" . "regexaction1" . "activeexpression" . "second.helloregex" . "startURLhttps://www.example.com/moodle/mod/quiz/view.php?id=1" . "sendBrowserExamKeybrowserWindowWebView3" . "examSessionClearCookiesOnStart" . "allowPreferencesWindow\n", ], 'blocked simple expessions' => [ (object) [ 'requiresafeexambrowser' => settings_provider::USE_SEB_CONFIG_MANUALLY, 'quizid' => 1, 'cmid' => 1, 'expressionsallowed' => '', 'regexallowed' => '', 'expressionsblocked' => "test.com\r\nsecond.hello", 'regexblocked' => '', ], "\n" . "\n" . "showTaskBar" . "allowWlanshowReloadButton" . "showTimeshowInputLanguageallowQuit" . "quitURLConfirmaudioControlEnabledaudioMute" . "browserMediaCaptureCamerabrowserMediaCaptureMicrophone" . "allowSpellCheckbrowserWindowAllowReloadURLFilterEnable" . "URLFilterEnableContentFilterURLFilterRules" . "action0active" . "expressiontest.com" . "regexaction0" . "activeexpression" . "second.helloregex" . "startURLhttps://www.example.com/moodle/mod/quiz/view.php?id=1" . "sendBrowserExamKeybrowserWindowWebView3" . "examSessionClearCookiesOnStart" . "allowPreferencesWindow\n", ], 'enabled regex expessions' => [ (object) [ 'requiresafeexambrowser' => settings_provider::USE_SEB_CONFIG_MANUALLY, 'quizid' => 1, 'cmid' => 1, 'expressionsallowed' => '', 'regexallowed' => "test.com\r\nsecond.hello", 'expressionsblocked' => '', 'regexblocked' => '', ], "\n" . "\n" . "showTaskBar" . "allowWlanshowReloadButton" . "showTimeshowInputLanguageallowQuit" . "quitURLConfirmaudioControlEnabledaudioMute" . "browserMediaCaptureCamerabrowserMediaCaptureMicrophone" . "allowSpellCheckbrowserWindowAllowReloadURLFilterEnable" . "URLFilterEnableContentFilterURLFilterRules" . "action1active" . "expressiontest.com" . "regexaction1" . "activeexpression" . "second.helloregex" . "startURLhttps://www.example.com/moodle/mod/quiz/view.php?id=1" . "sendBrowserExamKeybrowserWindowWebView3" . "examSessionClearCookiesOnStart" . "allowPreferencesWindow\n", ], 'blocked regex expessions' => [ (object) [ 'requiresafeexambrowser' => settings_provider::USE_SEB_CONFIG_MANUALLY, 'quizid' => 1, 'cmid' => 1, 'expressionsallowed' => '', 'regexallowed' => '', 'expressionsblocked' => '', 'regexblocked' => "test.com\r\nsecond.hello", ], "\n" . "\n" . "showTaskBar" . "allowWlanshowReloadButton" . "showTimeshowInputLanguageallowQuit" . "quitURLConfirmaudioControlEnabledaudioMute" . "browserMediaCaptureCamerabrowserMediaCaptureMicrophone" . "allowSpellCheckbrowserWindowAllowReloadURLFilterEnable" . "URLFilterEnableContentFilterURLFilterRules" . "action0active" . "expressiontest.com" . "regexaction0" . "activeexpression" . "second.helloregex" . "startURLhttps://www.example.com/moodle/mod/quiz/view.php?id=1" . "sendBrowserExamKeybrowserWindowWebView3" . "examSessionClearCookiesOnStart" . "allowPreferencesWindow\n", ], 'multiple simple expessions' => [ (object) [ 'requiresafeexambrowser' => settings_provider::USE_SEB_CONFIG_MANUALLY, 'quizid' => 1, 'cmid' => 1, 'expressionsallowed' => "*", 'regexallowed' => '', 'expressionsblocked' => '', 'regexblocked' => "test.com\r\nsecond.hello", ], "\n" . "\n" . "showTaskBar" . "allowWlanshowReloadButton" . "showTimeshowInputLanguageallowQuit" . "quitURLConfirmaudioControlEnabledaudioMute" . "browserMediaCaptureCamerabrowserMediaCaptureMicrophone" . "allowSpellCheckbrowserWindowAllowReloadURLFilterEnable" . "URLFilterEnableContentFilterURLFilterRulesaction" . "1activeexpression*" . "regex" . "action0active" . "expressiontest.com" . "regexaction0" . "activeexpression" . "second.helloregex" . "startURLhttps://www.example.com/moodle/mod/quiz/view.php?id=1" . "sendBrowserExamKeybrowserWindowWebView3" . "examSessionClearCookiesOnStart" . "allowPreferencesWindow\n", ], ]; } /** * Test that config and config key are null when expected. */ public function test_generates_config_values_as_null_when_expected(): void { $quizsettings = seb_quiz_settings::get_record(['quizid' => $this->quiz->id]); $this->assertNotNull($quizsettings->get_config()); $this->assertNotNull($quizsettings->get_config_key()); $quizsettings->set('requiresafeexambrowser', settings_provider::USE_SEB_NO); $quizsettings->save(); $quizsettings = seb_quiz_settings::get_record(['quizid' => $this->quiz->id]); $this->assertNull($quizsettings->get_config()); $this->assertNull($quizsettings->get_config()); $quizsettings->set('requiresafeexambrowser', settings_provider::USE_SEB_UPLOAD_CONFIG); $xml = file_get_contents(self::get_fixture_path(__NAMESPACE__, 'unencrypted.seb')); $this->create_module_test_file($xml, $this->quiz->cmid); $quizsettings->save(); $quizsettings = seb_quiz_settings::get_record(['quizid' => $this->quiz->id]); $this->assertNotNull($quizsettings->get_config()); $this->assertNotNull($quizsettings->get_config_key()); $quizsettings->set('requiresafeexambrowser', settings_provider::USE_SEB_CLIENT_CONFIG); $quizsettings->save(); $quizsettings = seb_quiz_settings::get_record(['quizid' => $this->quiz->id]); $this->assertNull($quizsettings->get_config()); $this->assertNull($quizsettings->get_config_key()); $template = $this->create_template(); $templateid = $template->get('id'); $this->save_settings_with_optional_template($quizsettings, settings_provider::USE_SEB_TEMPLATE, $templateid); $quizsettings = seb_quiz_settings::get_record(['quizid' => $this->quiz->id]); $this->assertNotNull($quizsettings->get_config()); $this->assertNotNull($quizsettings->get_config_key()); } /** * Test that quizsettings cache exists after creation. */ public function test_quizsettings_cache_exists_after_creation(): void { $expected = seb_quiz_settings::get_record(['quizid' => $this->quiz->id]); $this->assertEquals($expected->to_record(), \cache::make('quizaccess_seb', 'quizsettings')->get($this->quiz->id)); } /** * Test that quizsettings cache gets deleted after deletion. */ public function test_quizsettings_cache_purged_after_deletion(): void { $this->assertNotEmpty(\cache::make('quizaccess_seb', 'quizsettings')->get($this->quiz->id)); $quizsettings = seb_quiz_settings::get_record(['quizid' => $this->quiz->id]); $quizsettings->delete(); $this->assertFalse(\cache::make('quizaccess_seb', 'quizsettings')->get($this->quiz->id)); } /** * Test that we can get seb_quiz_settings by quiz id. */ public function test_get_quiz_settings_by_quiz_id(): void { $expected = seb_quiz_settings::get_record(['quizid' => $this->quiz->id]); $this->assertEquals($expected->to_record(), seb_quiz_settings::get_by_quiz_id($this->quiz->id)->to_record()); // Check that data is getting from cache. $expected->set('showsebtaskbar', 0); $this->assertNotEquals($expected->to_record(), seb_quiz_settings::get_by_quiz_id($this->quiz->id)->to_record()); // Now save and check that cached as been updated. $expected->save(); $this->assertEquals($expected->to_record(), seb_quiz_settings::get_by_quiz_id($this->quiz->id)->to_record()); // Returns false for non existing quiz. $this->assertFalse(seb_quiz_settings::get_by_quiz_id(7777777)); } /** * Test that SEB config cache exists after creation of the quiz. */ public function test_config_cache_exists_after_creation(): void { $this->assertNotEmpty(\cache::make('quizaccess_seb', 'config')->get($this->quiz->id)); } /** * Test that SEB config cache gets deleted after deletion. */ public function test_config_cache_purged_after_deletion(): void { $this->assertNotEmpty(\cache::make('quizaccess_seb', 'config')->get($this->quiz->id)); $quizsettings = seb_quiz_settings::get_record(['quizid' => $this->quiz->id]); $quizsettings->delete(); $this->assertFalse(\cache::make('quizaccess_seb', 'config')->get($this->quiz->id)); } /** * Test that we can get SEB config by quiz id. */ public function test_get_config_by_quiz_id(): void { $quizsettings = seb_quiz_settings::get_record(['quizid' => $this->quiz->id]); $expected = $quizsettings->get_config(); $this->assertEquals($expected, seb_quiz_settings::get_config_by_quiz_id($this->quiz->id)); // Check that data is getting from cache. $quizsettings->set('showsebtaskbar', 0); $this->assertNotEquals($quizsettings->get_config(), seb_quiz_settings::get_config_by_quiz_id($this->quiz->id)); // Now save and check that cached as been updated. $quizsettings->save(); $this->assertEquals($quizsettings->get_config(), seb_quiz_settings::get_config_by_quiz_id($this->quiz->id)); // Returns null for non existing quiz. $this->assertNull(seb_quiz_settings::get_config_by_quiz_id(7777777)); } /** * Test that SEB config key cache exists after creation of the quiz. */ public function test_config_key_cache_exists_after_creation(): void { $this->assertNotEmpty(\cache::make('quizaccess_seb', 'configkey')->get($this->quiz->id)); } /** * Test that SEB config key cache gets deleted after deletion. */ public function test_config_key_cache_purged_after_deletion(): void { $this->assertNotEmpty(\cache::make('quizaccess_seb', 'configkey')->get($this->quiz->id)); $quizsettings = seb_quiz_settings::get_record(['quizid' => $this->quiz->id]); $quizsettings->delete(); $this->assertFalse(\cache::make('quizaccess_seb', 'configkey')->get($this->quiz->id)); } /** * Test that we can get SEB config key by quiz id. */ public function test_get_config_key_by_quiz_id(): void { $quizsettings = seb_quiz_settings::get_record(['quizid' => $this->quiz->id]); $expected = $quizsettings->get_config_key(); $this->assertEquals($expected, seb_quiz_settings::get_config_key_by_quiz_id($this->quiz->id)); // Check that data is getting from cache. $quizsettings->set('showsebtaskbar', 0); $this->assertNotEquals($quizsettings->get_config_key(), seb_quiz_settings::get_config_key_by_quiz_id($this->quiz->id)); // Now save and check that cached as been updated. $quizsettings->save(); $this->assertEquals($quizsettings->get_config_key(), seb_quiz_settings::get_config_key_by_quiz_id($this->quiz->id)); // Returns null for non existing quiz. $this->assertNull(seb_quiz_settings::get_config_key_by_quiz_id(7777777)); } } PK9B\$PP/accessrule/seb/tests/settings_provider_test.phpnu[. namespace quizaccess_seb; defined('MOODLE_INTERNAL') || die(); require_once(__DIR__ . '/test_helper_trait.php'); /** * PHPUnit tests for settings_provider. * * @package quizaccess_seb * @author Andrew Madden * @copyright 2020 Catalyst IT * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class settings_provider_test extends \advanced_testcase { use \quizaccess_seb_test_helper_trait; /** * Mocked quiz form instance. * @var \mod_quiz_mod_form */ protected $mockedquizform; /** * Test moodle form. * @var \MoodleQuickForm */ protected $mockedform; /** * Context for testing. * @var \context */ protected $context; /** * Test user. * @var \stdClass */ protected $user; /** * Test role ID. * @var int */ protected $roleid; /** * Helper method to set up form mocks. */ protected function set_up_form_mocks() { if (empty($this->context)) { $this->context = \context_module::instance($this->quiz->cmid); } $this->mockedquizform = $this->createMock('mod_quiz_mod_form'); $this->mockedquizform->method('get_context')->willReturn($this->context); $this->mockedquizform->method('get_instance')->willReturn($this->quiz->id); $this->mockedform = new \MoodleQuickForm('test', 'post', ''); $this->mockedform->addElement('static', 'security'); } /** * Helper method to set up user and role for testing. */ protected function set_up_user_and_role() { $this->user = $this->getDataGenerator()->create_user(); $this->setUser($this->user); $this->roleid = $this->getDataGenerator()->create_role(); $this->getDataGenerator()->role_assign($this->roleid, $this->user->id, $this->context->id); } /** * Capability data for testing. * * @return array */ public static function settings_capability_data_provider(): array { $data = []; // Build first level SEB config settings. Any of this setting let us use SEB manual config. foreach (settings_provider::get_seb_settings_map()[settings_provider::USE_SEB_CONFIG_MANUALLY] as $name => $children) { if (key_exists($name, settings_provider::get_seb_config_elements())) { $cap = settings_provider::build_setting_capability_name($name); $data[] = [$cap]; } } return $data; } /** * Test that settings types to be added to quiz settings, are part of quiz_settings persistent class. */ public function test_setting_elements_are_part_of_quiz_settings_table(): void { $dbsettings = (array) (new seb_quiz_settings())->to_record(); $settingelements = settings_provider::get_seb_config_elements(); $settingelements = (array) $this->strip_all_prefixes((object) $settingelements); // Get all elements to be added to form, that are not in the persistent quiz_settings class. $diffelements = array_diff_key($settingelements, $dbsettings); $this->assertEmpty($diffelements); } /** * Make sure that all SEB settings have related capabilities. */ public function test_that_all_seb_settings_have_capabilities(): void { foreach (settings_provider::get_seb_config_elements() as $name => $notused) { $this->assertNotEmpty(get_capability_info(settings_provider::build_setting_capability_name($name))); } } /** * Test that setting defaults only refer to settings defined in setting types. */ public function test_setting_defaults_are_part_of_file_types(): void { $settingelements = settings_provider::get_seb_config_elements(); $settingdefaults = settings_provider::get_seb_config_element_defaults(); // Get all defaults that have no matching element in settings types. $diffelements = array_diff_key($settingdefaults, $settingelements); $this->assertEmpty($diffelements); } /** * Test that setting types only refer to settings defined in setting types. */ public function test_setting_types_are_part_of_file_types(): void { $settingelements = settings_provider::get_seb_config_elements(); $settingtypes = settings_provider::get_seb_config_element_types(); // Get all defaults that have no matching element in settings types. $diffelements = array_diff_key($settingtypes, $settingelements); $this->assertEmpty($diffelements); } /** * Helper method to assert hide if element. * @param hideif_rule $hideif Rule to check. * @param string $element Expected element. * @param string $dependantname Expected dependant element name. * @param string $condition Expected condition. * @param mixed $value Expected value. */ protected function assert_hide_if(hideif_rule $hideif, $element, $dependantname, $condition, $value) { $this->assertEquals($element, $hideif->get_element()); $this->assertEquals($dependantname, $hideif->get_dependantname()); $this->assertEquals($condition, $hideif->get_condition()); $this->assertEquals($value, $hideif->get_dependantvalue()); } /** * Test hideif rules. */ public function test_hideifs(): void { $settinghideifs = settings_provider::get_quiz_hideifs(); $this->assertCount(25, $settinghideifs); $this->assertArrayHasKey('seb_templateid', $settinghideifs); $this->assertCount(1, $settinghideifs['seb_templateid']); $this->assert_hide_if( $settinghideifs['seb_templateid'][0], 'seb_templateid', 'seb_requiresafeexambrowser', 'noteq', settings_provider::USE_SEB_TEMPLATE ); $this->assertArrayHasKey('filemanager_sebconfigfile', $settinghideifs); $this->assertCount(1, $settinghideifs['filemanager_sebconfigfile']); $this->assert_hide_if( $settinghideifs['filemanager_sebconfigfile'][0], 'filemanager_sebconfigfile', 'seb_requiresafeexambrowser', 'noteq', settings_provider::USE_SEB_UPLOAD_CONFIG ); $this->assertArrayHasKey('seb_showsebtaskbar', $settinghideifs); $this->assertCount(1, $settinghideifs['seb_showsebtaskbar']); $this->assert_hide_if( $settinghideifs['seb_showsebtaskbar'][0], 'seb_showsebtaskbar', 'seb_requiresafeexambrowser', 'noteq', settings_provider::USE_SEB_CONFIG_MANUALLY ); $this->assertArrayHasKey('seb_showwificontrol', $settinghideifs); $this->assertCount(2, $settinghideifs['seb_showwificontrol']); $this->assert_hide_if( $settinghideifs['seb_showwificontrol'][0], 'seb_showwificontrol', 'seb_requiresafeexambrowser', 'noteq', settings_provider::USE_SEB_CONFIG_MANUALLY ); $this->assert_hide_if( $settinghideifs['seb_showwificontrol'][1], 'seb_showwificontrol', 'seb_showsebtaskbar', 'eq', 0 ); $this->assertArrayHasKey('seb_showreloadbutton', $settinghideifs); $this->assertCount(2, $settinghideifs['seb_showreloadbutton']); $this->assert_hide_if( $settinghideifs['seb_showreloadbutton'][0], 'seb_showreloadbutton', 'seb_requiresafeexambrowser', 'noteq', settings_provider::USE_SEB_CONFIG_MANUALLY ); $this->assert_hide_if( $settinghideifs['seb_showreloadbutton'][1], 'seb_showreloadbutton', 'seb_showsebtaskbar', 'eq', 0 ); $this->assertArrayHasKey('seb_showtime', $settinghideifs); $this->assertCount(2, $settinghideifs['seb_showtime']); $this->assert_hide_if( $settinghideifs['seb_showtime'][0], 'seb_showtime', 'seb_requiresafeexambrowser', 'noteq', settings_provider::USE_SEB_CONFIG_MANUALLY ); $this->assert_hide_if( $settinghideifs['seb_showtime'][1], 'seb_showtime', 'seb_showsebtaskbar', 'eq', 0 ); $this->assertArrayHasKey('seb_showkeyboardlayout', $settinghideifs); $this->assertCount(2, $settinghideifs['seb_showkeyboardlayout']); $this->assert_hide_if( $settinghideifs['seb_showkeyboardlayout'][0], 'seb_showkeyboardlayout', 'seb_requiresafeexambrowser', 'noteq', settings_provider::USE_SEB_CONFIG_MANUALLY ); $this->assert_hide_if( $settinghideifs['seb_showkeyboardlayout'][1], 'seb_showkeyboardlayout', 'seb_showsebtaskbar', 'eq', 0 ); $this->assertArrayHasKey('seb_allowuserquitseb', $settinghideifs); $this->assertCount(3, $settinghideifs['seb_allowuserquitseb']); $this->assert_hide_if( $settinghideifs['seb_allowuserquitseb'][0], 'seb_allowuserquitseb', 'seb_requiresafeexambrowser', 'eq', settings_provider::USE_SEB_NO ); $this->assert_hide_if( $settinghideifs['seb_allowuserquitseb'][1], 'seb_allowuserquitseb', 'seb_requiresafeexambrowser', 'eq', settings_provider::USE_SEB_CLIENT_CONFIG ); $this->assert_hide_if( $settinghideifs['seb_allowuserquitseb'][2], 'seb_allowuserquitseb', 'seb_requiresafeexambrowser', 'eq', settings_provider::USE_SEB_UPLOAD_CONFIG ); $this->assertArrayHasKey('seb_quitpassword', $settinghideifs); $this->assertCount(4, $settinghideifs['seb_quitpassword']); $this->assert_hide_if( $settinghideifs['seb_quitpassword'][0], 'seb_quitpassword', 'seb_requiresafeexambrowser', 'eq', settings_provider::USE_SEB_NO ); $this->assert_hide_if( $settinghideifs['seb_quitpassword'][1], 'seb_quitpassword', 'seb_requiresafeexambrowser', 'eq', settings_provider::USE_SEB_CLIENT_CONFIG ); $this->assert_hide_if( $settinghideifs['seb_quitpassword'][2], 'seb_quitpassword', 'seb_requiresafeexambrowser', 'eq', settings_provider::USE_SEB_UPLOAD_CONFIG ); $this->assert_hide_if( $settinghideifs['seb_quitpassword'][3], 'seb_quitpassword', 'seb_allowuserquitseb', 'eq', 0 ); $this->assertArrayHasKey('seb_linkquitseb', $settinghideifs); $this->assertCount(1, $settinghideifs['seb_linkquitseb']); $this->assert_hide_if( $settinghideifs['seb_linkquitseb'][0], 'seb_linkquitseb', 'seb_requiresafeexambrowser', 'noteq', settings_provider::USE_SEB_CONFIG_MANUALLY ); $this->assertArrayHasKey('seb_userconfirmquit', $settinghideifs); $this->assertCount(1, $settinghideifs['seb_userconfirmquit']); $this->assert_hide_if( $settinghideifs['seb_userconfirmquit'][0], 'seb_userconfirmquit', 'seb_requiresafeexambrowser', 'noteq', settings_provider::USE_SEB_CONFIG_MANUALLY ); $this->assertArrayHasKey('seb_enableaudiocontrol', $settinghideifs); $this->assertCount(1, $settinghideifs['seb_enableaudiocontrol']); $this->assert_hide_if( $settinghideifs['seb_enableaudiocontrol'][0], 'seb_enableaudiocontrol', 'seb_requiresafeexambrowser', 'noteq', settings_provider::USE_SEB_CONFIG_MANUALLY ); $this->assertArrayHasKey('seb_allowcapturecamera', $settinghideifs); $this->assertCount(1, $settinghideifs['seb_allowcapturecamera']); $this->assert_hide_if( $settinghideifs['seb_allowcapturecamera'][0], 'seb_allowcapturecamera', 'seb_requiresafeexambrowser', 'noteq', settings_provider::USE_SEB_CONFIG_MANUALLY ); $this->assertArrayHasKey('seb_allowcapturemicrophone', $settinghideifs); $this->assertCount(1, $settinghideifs['seb_allowcapturemicrophone']); $this->assert_hide_if( $settinghideifs['seb_allowcapturemicrophone'][0], 'seb_allowcapturemicrophone', 'seb_requiresafeexambrowser', 'noteq', settings_provider::USE_SEB_CONFIG_MANUALLY ); $this->assertArrayHasKey('seb_muteonstartup', $settinghideifs); $this->assertCount(2, $settinghideifs['seb_muteonstartup']); $this->assert_hide_if( $settinghideifs['seb_muteonstartup'][0], 'seb_muteonstartup', 'seb_requiresafeexambrowser', 'noteq', settings_provider::USE_SEB_CONFIG_MANUALLY ); $this->assert_hide_if( $settinghideifs['seb_muteonstartup'][1], 'seb_muteonstartup', 'seb_enableaudiocontrol', 'eq', 0 ); $this->assertArrayHasKey('seb_allowspellchecking', $settinghideifs); $this->assertCount(1, $settinghideifs['seb_allowspellchecking']); $this->assert_hide_if( $settinghideifs['seb_allowspellchecking'][0], 'seb_allowspellchecking', 'seb_requiresafeexambrowser', 'noteq', settings_provider::USE_SEB_CONFIG_MANUALLY ); $this->assertArrayHasKey('seb_allowreloadinexam', $settinghideifs); $this->assertCount(1, $settinghideifs['seb_allowreloadinexam']); $this->assert_hide_if( $settinghideifs['seb_allowreloadinexam'][0], 'seb_allowreloadinexam', 'seb_requiresafeexambrowser', 'noteq', settings_provider::USE_SEB_CONFIG_MANUALLY ); $this->assertArrayHasKey('seb_activateurlfiltering', $settinghideifs); $this->assertCount(1, $settinghideifs['seb_activateurlfiltering']); $this->assert_hide_if( $settinghideifs['seb_activateurlfiltering'][0], 'seb_activateurlfiltering', 'seb_requiresafeexambrowser', 'noteq', settings_provider::USE_SEB_CONFIG_MANUALLY ); $this->assertArrayHasKey('seb_filterembeddedcontent', $settinghideifs); $this->assertCount(2, $settinghideifs['seb_filterembeddedcontent']); $this->assert_hide_if( $settinghideifs['seb_filterembeddedcontent'][0], 'seb_filterembeddedcontent', 'seb_requiresafeexambrowser', 'noteq', settings_provider::USE_SEB_CONFIG_MANUALLY ); $this->assert_hide_if( $settinghideifs['seb_filterembeddedcontent'][1], 'seb_filterembeddedcontent', 'seb_activateurlfiltering', 'eq', 0 ); $this->assertArrayHasKey('seb_expressionsallowed', $settinghideifs); $this->assertCount(2, $settinghideifs['seb_expressionsallowed']); $this->assert_hide_if( $settinghideifs['seb_expressionsallowed'][0], 'seb_expressionsallowed', 'seb_requiresafeexambrowser', 'noteq', settings_provider::USE_SEB_CONFIG_MANUALLY ); $this->assert_hide_if( $settinghideifs['seb_expressionsallowed'][1], 'seb_expressionsallowed', 'seb_activateurlfiltering', 'eq', 0 ); $this->assertArrayHasKey('seb_regexallowed', $settinghideifs); $this->assertCount(2, $settinghideifs['seb_regexallowed']); $this->assert_hide_if( $settinghideifs['seb_regexallowed'][0], 'seb_regexallowed', 'seb_requiresafeexambrowser', 'noteq', settings_provider::USE_SEB_CONFIG_MANUALLY ); $this->assert_hide_if( $settinghideifs['seb_regexallowed'][1], 'seb_regexallowed', 'seb_activateurlfiltering', 'eq', 0 ); $this->assertArrayHasKey('seb_expressionsblocked', $settinghideifs); $this->assertCount(2, $settinghideifs['seb_expressionsblocked']); $this->assert_hide_if( $settinghideifs['seb_expressionsblocked'][0], 'seb_expressionsblocked', 'seb_requiresafeexambrowser', 'noteq', settings_provider::USE_SEB_CONFIG_MANUALLY ); $this->assert_hide_if( $settinghideifs['seb_expressionsblocked'][1], 'seb_expressionsblocked', 'seb_activateurlfiltering', 'eq', 0 ); $this->assertArrayHasKey('seb_regexblocked', $settinghideifs); $this->assertCount(2, $settinghideifs['seb_regexblocked']); $this->assert_hide_if( $settinghideifs['seb_regexblocked'][0], 'seb_regexblocked', 'seb_requiresafeexambrowser', 'noteq', settings_provider::USE_SEB_CONFIG_MANUALLY ); $this->assert_hide_if( $settinghideifs['seb_regexblocked'][1], 'seb_regexblocked', 'seb_activateurlfiltering', 'eq', 0 ); $this->assertArrayHasKey('seb_showsebdownloadlink', $settinghideifs); $this->assertCount(1, $settinghideifs['seb_showsebdownloadlink']); $this->assert_hide_if( $settinghideifs['seb_showsebdownloadlink'][0], 'seb_showsebdownloadlink', 'seb_requiresafeexambrowser', 'eq', settings_provider::USE_SEB_NO ); $this->assertArrayHasKey('seb_allowedbrowserexamkeys', $settinghideifs); $this->assertCount(3, $settinghideifs['seb_allowedbrowserexamkeys']); $this->assert_hide_if( $settinghideifs['seb_allowedbrowserexamkeys'][0], 'seb_allowedbrowserexamkeys', 'seb_requiresafeexambrowser', 'eq', settings_provider::USE_SEB_NO ); $this->assert_hide_if( $settinghideifs['seb_allowedbrowserexamkeys'][1], 'seb_allowedbrowserexamkeys', 'seb_requiresafeexambrowser', 'eq', settings_provider::USE_SEB_CONFIG_MANUALLY ); $this->assert_hide_if( $settinghideifs['seb_allowedbrowserexamkeys'][2], 'seb_allowedbrowserexamkeys', 'seb_requiresafeexambrowser', 'eq', settings_provider::USE_SEB_TEMPLATE ); } /** * Test that setting hideif rules only refer to settings defined in setting types, including the conditions. */ public function test_setting_hideifs_are_part_of_file_types(): void { $settingelements = settings_provider::get_seb_config_elements(); $settinghideifs = settings_provider::get_quiz_hideifs(); // Add known additional elements. $settingelements['seb_templateid'] = ''; $settingelements['filemanager_sebconfigfile'] = ''; $settingelements['seb_showsebdownloadlink'] = ''; $settingelements['seb_allowedbrowserexamkeys'] = ''; // Get all defaults that have no matching element in settings types. $diffelements = array_diff_key($settinghideifs, $settingelements); // Check no diff for elements to hide. $this->assertEmpty($diffelements); // Check each element's to hide conditions that each condition refers to element in settings types. foreach ($settinghideifs as $conditions) { foreach ($conditions as $condition) { $this->assertTrue(array_key_exists($condition->get_element(), $settingelements)); } } } /** * Test that exception thrown if we try to build capability name from the incorrect setting name. */ public function test_build_setting_capability_name_incorrect_setting(): void { $this->expectException(\coding_exception::class); $this->expectExceptionMessage('Incorrect SEB quiz setting broken'); $broken = settings_provider::build_setting_capability_name('broken'); } /** * Test we can build capability name from the the setting name. */ public function test_build_setting_capability_name_correct_setting(): void { foreach (settings_provider::get_seb_config_elements() as $name => $type) { $expected = 'quizaccess/seb:manage_' . $name; $actual = settings_provider::build_setting_capability_name($name); $this->assertSame($expected, $actual); } } /** * Test can check if can manage SEB settings respecting settings structure. */ public function test_can_manage_seb_config_setting(): void { $this->resetAfterTest(); $this->setAdminUser(); $this->course = $this->getDataGenerator()->create_course(); $this->quiz = $this->getDataGenerator()->create_module('quiz', ['course' => $this->course->id]); $this->context = \context_module::instance($this->quiz->cmid); $this->set_up_user_and_role(); foreach (settings_provider::get_seb_settings_map()[settings_provider::USE_SEB_CONFIG_MANUALLY] as $setting => $children) { // Skip not SEB setting. if ($setting == 'seb_showsebdownloadlink') { continue; } $this->assertFalse(settings_provider::can_manage_seb_config_setting($setting, $this->context)); foreach ($children as $child => $empty) { $this->assertFalse(settings_provider::can_manage_seb_config_setting($child, $this->context)); // Assign child capability without having parent one. Should not have access to manage child. $childcap = settings_provider::build_setting_capability_name($child); assign_capability($childcap, CAP_ALLOW, $this->roleid, $this->context->id); $this->assertFalse(settings_provider::can_manage_seb_config_setting($child, $this->context)); } // Assign parent capability. Should be able to manage children now. $parentcap = settings_provider::build_setting_capability_name($setting); assign_capability($parentcap, CAP_ALLOW, $this->roleid, $this->context->id); $this->assertTrue(settings_provider::can_manage_seb_config_setting($setting, $this->context)); foreach ($children as $child => $empty) { $this->assertTrue(settings_provider::can_manage_seb_config_setting($child, $this->context)); } } } /** * Test SEB usage options. * * @param string $settingcapability Setting capability to check options against. * * @dataProvider settings_capability_data_provider */ public function test_get_requiresafeexambrowser_options($settingcapability): void { $this->resetAfterTest(); $this->setAdminUser(); $this->course = $this->getDataGenerator()->create_course(); $this->quiz = $this->getDataGenerator()->create_module('quiz', ['course' => $this->course->id]); $this->context = \context_module::instance($this->quiz->cmid); $options = settings_provider::get_requiresafeexambrowser_options($this->context); $this->assertCount(4, $options); $this->assertTrue(array_key_exists(settings_provider::USE_SEB_NO, $options)); $this->assertTrue(array_key_exists(settings_provider::USE_SEB_CONFIG_MANUALLY, $options)); $this->assertFalse(array_key_exists(settings_provider::USE_SEB_TEMPLATE, $options)); $this->assertTrue(array_key_exists(settings_provider::USE_SEB_UPLOAD_CONFIG, $options)); $this->assertTrue(array_key_exists(settings_provider::USE_SEB_CLIENT_CONFIG, $options)); // Create a template. $this->create_template(); // The template options should be visible now. $options = settings_provider::get_requiresafeexambrowser_options($this->context); $this->assertCount(5, $options); $this->assertTrue(array_key_exists(settings_provider::USE_SEB_TEMPLATE, $options)); // A new user does not have the capability to use the file manager and template. $this->set_up_user_and_role(); $options = settings_provider::get_requiresafeexambrowser_options($this->context); $this->assertCount(1, $options); $this->assertFalse(array_key_exists(settings_provider::USE_SEB_CONFIG_MANUALLY, $options)); $this->assertFalse(array_key_exists(settings_provider::USE_SEB_TEMPLATE, $options)); $this->assertFalse(array_key_exists(settings_provider::USE_SEB_UPLOAD_CONFIG, $options)); $this->assertFalse(array_key_exists(settings_provider::USE_SEB_CLIENT_CONFIG, $options)); $this->assertTrue(array_key_exists(settings_provider::USE_SEB_NO, $options)); assign_capability($settingcapability, CAP_ALLOW, $this->roleid, $this->context->id); $options = settings_provider::get_requiresafeexambrowser_options($this->context); $this->assertCount(1, $options); $this->assertFalse(array_key_exists(settings_provider::USE_SEB_CONFIG_MANUALLY, $options)); $this->assertFalse(array_key_exists(settings_provider::USE_SEB_TEMPLATE, $options)); $this->assertFalse(array_key_exists(settings_provider::USE_SEB_UPLOAD_CONFIG, $options)); $this->assertFalse(array_key_exists(settings_provider::USE_SEB_CLIENT_CONFIG, $options)); $this->assertTrue(array_key_exists(settings_provider::USE_SEB_NO, $options)); assign_capability('quizaccess/seb:manage_seb_configuremanually', CAP_ALLOW, $this->roleid, $this->context->id); $options = settings_provider::get_requiresafeexambrowser_options($this->context); $this->assertCount(2, $options); $this->assertTrue(array_key_exists(settings_provider::USE_SEB_CONFIG_MANUALLY, $options)); $this->assertFalse(array_key_exists(settings_provider::USE_SEB_TEMPLATE, $options)); $this->assertFalse(array_key_exists(settings_provider::USE_SEB_UPLOAD_CONFIG, $options)); $this->assertFalse(array_key_exists(settings_provider::USE_SEB_CLIENT_CONFIG, $options)); $this->assertTrue(array_key_exists(settings_provider::USE_SEB_NO, $options)); assign_capability('quizaccess/seb:manage_seb_usesebclientconfig', CAP_ALLOW, $this->roleid, $this->context->id); $options = settings_provider::get_requiresafeexambrowser_options($this->context); $this->assertCount(3, $options); $this->assertTrue(array_key_exists(settings_provider::USE_SEB_CONFIG_MANUALLY, $options)); $this->assertFalse(array_key_exists(settings_provider::USE_SEB_TEMPLATE, $options)); $this->assertFalse(array_key_exists(settings_provider::USE_SEB_UPLOAD_CONFIG, $options)); $this->assertTrue(array_key_exists(settings_provider::USE_SEB_CLIENT_CONFIG, $options)); $this->assertTrue(array_key_exists(settings_provider::USE_SEB_NO, $options)); assign_capability('quizaccess/seb:manage_seb_templateid', CAP_ALLOW, $this->roleid, $this->context->id); $options = settings_provider::get_requiresafeexambrowser_options($this->context); $this->assertCount(4, $options); $this->assertTrue(array_key_exists(settings_provider::USE_SEB_CONFIG_MANUALLY, $options)); $this->assertTrue(array_key_exists(settings_provider::USE_SEB_TEMPLATE, $options)); $this->assertFalse(array_key_exists(settings_provider::USE_SEB_UPLOAD_CONFIG, $options)); $this->assertTrue(array_key_exists(settings_provider::USE_SEB_CLIENT_CONFIG, $options)); $this->assertTrue(array_key_exists(settings_provider::USE_SEB_NO, $options)); assign_capability('quizaccess/seb:manage_filemanager_sebconfigfile', CAP_ALLOW, $this->roleid, $this->context->id); $options = settings_provider::get_requiresafeexambrowser_options($this->context); $this->assertCount(5, $options); $this->assertTrue(array_key_exists(settings_provider::USE_SEB_CONFIG_MANUALLY, $options)); $this->assertTrue(array_key_exists(settings_provider::USE_SEB_TEMPLATE, $options)); $this->assertTrue(array_key_exists(settings_provider::USE_SEB_UPLOAD_CONFIG, $options)); $this->assertTrue(array_key_exists(settings_provider::USE_SEB_CLIENT_CONFIG, $options)); $this->assertTrue(array_key_exists(settings_provider::USE_SEB_NO, $options)); } /** * Test SEB usage options with conflicting permissions. */ public function test_get_requiresafeexambrowser_options_with_conflicting_permissions(): void { $this->resetAfterTest(); $this->setAdminUser(); $this->course = $this->getDataGenerator()->create_course(); $this->quiz = $this->create_test_quiz($this->course, settings_provider::USE_SEB_CONFIG_MANUALLY); $this->context = \context_module::instance($this->quiz->cmid); $template = $this->create_template(); $settings = seb_quiz_settings::get_record(['quizid' => $this->quiz->id]); $settings->set('templateid', $template->get('id')); $settings->set('requiresafeexambrowser', settings_provider::USE_SEB_TEMPLATE); $settings->save(); $this->set_up_user_and_role(); $options = settings_provider::get_requiresafeexambrowser_options($this->context); // If there is nay conflict we return full list of options. $this->assertCount(5, $options); $this->assertTrue(array_key_exists(settings_provider::USE_SEB_TEMPLATE, $options)); } /** * Test that SEB options and templates are frozen if conflicting permissions. */ public function test_form_elements_are_frozen_if_conflicting_permissions(): void { $this->resetAfterTest(); $this->setAdminUser(); $this->course = $this->getDataGenerator()->create_course(); $this->quiz = $this->create_test_quiz($this->course, settings_provider::USE_SEB_CONFIG_MANUALLY); $this->context = \context_module::instance($this->quiz->cmid); // Setup conflicting permissions. $template = $this->create_template(); $settings = seb_quiz_settings::get_record(['quizid' => $this->quiz->id]); $settings->set('templateid', $template->get('id')); $settings->set('requiresafeexambrowser', settings_provider::USE_SEB_TEMPLATE); $settings->save(); $this->set_up_user_and_role(); assign_capability('quizaccess/seb:manage_seb_requiresafeexambrowser', CAP_ALLOW, $this->roleid, $this->context->id); assign_capability('quizaccess/seb:manage_seb_showsebdownloadlink', CAP_ALLOW, $this->roleid, $this->context->id); assign_capability('quizaccess/seb:manage_seb_allowedbrowserexamkeys', CAP_ALLOW, $this->roleid, $this->context->id); $this->set_up_form_mocks(); settings_provider::add_seb_settings_fields($this->mockedquizform, $this->mockedform); $this->assertTrue($this->mockedform->isElementFrozen('seb_requiresafeexambrowser')); $this->assertTrue($this->mockedform->isElementFrozen('seb_templateid')); $this->assertTrue($this->mockedform->isElementFrozen('seb_showsebdownloadlink')); $this->assertTrue($this->mockedform->isElementFrozen('seb_allowedbrowserexamkeys')); } /** * Test that All settings are frozen if quiz was attempted and use seb with manual settings. */ public function test_form_elements_are_locked_when_quiz_attempted_manual(): void { $this->resetAfterTest(); $this->course = $this->getDataGenerator()->create_course(); $this->quiz = $this->create_test_quiz($this->course, settings_provider::USE_SEB_CONFIG_MANUALLY); $this->context = \context_module::instance($this->quiz->cmid); $user = $this->getDataGenerator()->create_user(); $this->attempt_quiz($this->quiz, $user); $this->setAdminUser(); $this->set_up_form_mocks(); settings_provider::add_seb_settings_fields($this->mockedquizform, $this->mockedform); $this->assertTrue($this->mockedform->isElementFrozen('seb_requiresafeexambrowser')); $this->assertTrue($this->mockedform->elementExists('filemanager_sebconfigfile')); $this->assertFalse($this->mockedform->elementExists('seb_templateid')); $this->assertTrue($this->mockedform->isElementFrozen('seb_showsebdownloadlink')); $this->assertTrue($this->mockedform->isElementFrozen('seb_allowedbrowserexamkeys')); foreach (settings_provider::get_seb_config_elements() as $name => $type) { $this->assertTrue($this->mockedform->isElementFrozen($name)); } } /** * Test that All settings are frozen if a quiz was attempted and use template. */ public function test_form_elements_are_locked_when_quiz_attempted_template(): void { $this->resetAfterTest(); $this->setAdminUser(); $this->course = $this->getDataGenerator()->create_course(); $this->quiz = $this->create_test_quiz($this->course, settings_provider::USE_SEB_CONFIG_MANUALLY); $this->context = \context_module::instance($this->quiz->cmid); $template = $this->create_template(); $settings = seb_quiz_settings::get_record(['quizid' => $this->quiz->id]); $settings->set('templateid', $template->get('id')); $settings->set('requiresafeexambrowser', settings_provider::USE_SEB_TEMPLATE); $settings->save(); $user = $this->getDataGenerator()->create_user(); $this->attempt_quiz($this->quiz, $user); $this->setAdminUser(); $this->set_up_form_mocks(); settings_provider::add_seb_settings_fields($this->mockedquizform, $this->mockedform); $this->assertTrue($this->mockedform->isElementFrozen('seb_requiresafeexambrowser')); $this->assertTrue($this->mockedform->elementExists('filemanager_sebconfigfile')); $this->assertTrue($this->mockedform->isElementFrozen('seb_templateid')); $this->assertTrue($this->mockedform->isElementFrozen('seb_showsebdownloadlink')); $this->assertTrue($this->mockedform->isElementFrozen('seb_allowedbrowserexamkeys')); foreach (settings_provider::get_seb_config_elements() as $name => $type) { $this->assertTrue($this->mockedform->isElementFrozen($name)); } } /** * Test Show Safe Exam Browser download button setting in the form. */ public function test_showsebdownloadlink_in_form(): void { $this->resetAfterTest(); $this->setAdminUser(); $this->course = $this->getDataGenerator()->create_course(); $this->quiz = $this->create_test_quiz($this->course, settings_provider::USE_SEB_CONFIG_MANUALLY); $this->context = \context_module::instance($this->quiz->cmid); $this->set_up_user_and_role(); assign_capability('quizaccess/seb:manage_seb_requiresafeexambrowser', CAP_ALLOW, $this->roleid, $this->context->id); $this->set_up_form_mocks(); // Shouldn't be in the form if no permissions. settings_provider::add_seb_settings_fields($this->mockedquizform, $this->mockedform); $this->assertFalse($this->mockedform->elementExists('seb_showsebdownloadlink')); // Should be in the form if we grant require permissions. assign_capability('quizaccess/seb:manage_seb_showsebdownloadlink', CAP_ALLOW, $this->roleid, $this->context->id); settings_provider::add_seb_settings_fields($this->mockedquizform, $this->mockedform); $this->assertTrue($this->mockedform->elementExists('seb_showsebdownloadlink')); } /** * Test Allowed Browser Exam Keys setting in the form. */ public function test_allowedbrowserexamkeys_in_form(): void { $this->resetAfterTest(); $this->setAdminUser(); $this->course = $this->getDataGenerator()->create_course(); $this->quiz = $this->create_test_quiz($this->course, settings_provider::USE_SEB_CLIENT_CONFIG); $this->context = \context_module::instance($this->quiz->cmid); $this->set_up_user_and_role(); assign_capability('quizaccess/seb:manage_seb_requiresafeexambrowser', CAP_ALLOW, $this->roleid, $this->context->id); $this->set_up_form_mocks(); // Shouldn't be in the form if no permissions. settings_provider::add_seb_settings_fields($this->mockedquizform, $this->mockedform); $this->assertFalse($this->mockedform->elementExists('seb_allowedbrowserexamkeys')); // Should be in the form if we grant require permissions. assign_capability('quizaccess/seb:manage_seb_allowedbrowserexamkeys', CAP_ALLOW, $this->roleid, $this->context->id); settings_provider::add_seb_settings_fields($this->mockedquizform, $this->mockedform); $this->assertTrue($this->mockedform->elementExists('seb_allowedbrowserexamkeys')); } /** * Test the validation of a seb config file. */ public function test_validate_draftarea_configfile_success(): void { $this->resetAfterTest(); $user = $this->getDataGenerator()->create_user(); $this->setUser($user); $xml = "\n" . "\n" . "hashedQuitPasswordhashedpassword" . "allowWlan\n"; $itemid = $this->create_test_draftarea_file($xml); $errors = settings_provider::validate_draftarea_configfile($itemid); $this->assertEmpty($errors); } /** * Test the validation of a missing seb config file. */ public function test_validate_draftarea_configfile_failure(): void { $this->resetAfterTest(); $user = $this->getDataGenerator()->create_user(); $this->setUser($user); $xml = "This is not a config file."; $itemid = $this->create_test_draftarea_file($xml); $errors = settings_provider::validate_draftarea_configfile($itemid); $this->assertEquals($errors, new \lang_string('fileparsefailed', 'quizaccess_seb')); } /** * Test obtaining the draftarea content. */ public function test_get_current_user_draft_file(): void { $this->resetAfterTest(); $user = $this->getDataGenerator()->create_user(); $this->setUser($user); $xml = file_get_contents(self::get_fixture_path(__NAMESPACE__, 'unencrypted.seb')); $itemid = $this->create_test_draftarea_file($xml); $file = settings_provider::get_current_user_draft_file($itemid); $content = $file->get_content(); $this->assertEquals($xml, $content); } /** * Test saving files from the user draft area into the quiz context area storage. */ public function test_save_filemanager_sebconfigfile_draftarea(): void { $this->resetAfterTest(); $this->course = $this->getDataGenerator()->create_course(); $this->quiz = $this->getDataGenerator()->create_module('quiz', ['course' => $this->course->id]); $this->context = \context_module::instance($this->quiz->cmid); $this->set_up_user_and_role(); $xml = file_get_contents(self::get_fixture_path(__NAMESPACE__, 'unencrypted.seb')); $draftitemid = $this->create_test_draftarea_file($xml); settings_provider::save_filemanager_sebconfigfile_draftarea($draftitemid, $this->quiz->cmid); $fs = get_file_storage(); $files = $fs->get_area_files($this->context->id, 'quizaccess_seb', 'filemanager_sebconfigfile'); $this->assertCount(2, $files); } /** * Test deleting the $this->quiz->cmid itemid from the file area. */ public function test_delete_uploaded_config_file(): void { $this->resetAfterTest(); $this->course = $this->getDataGenerator()->create_course(); $this->quiz = $this->getDataGenerator()->create_module('quiz', ['course' => $this->course->id]); $this->context = \context_module::instance($this->quiz->cmid); $this->set_up_user_and_role(); $xml = file_get_contents(self::get_fixture_path(__NAMESPACE__, 'unencrypted.seb')); $draftitemid = $this->create_test_draftarea_file($xml); settings_provider::save_filemanager_sebconfigfile_draftarea($draftitemid, $this->quiz->cmid); $fs = get_file_storage(); $files = $fs->get_area_files($this->context->id, 'quizaccess_seb', 'filemanager_sebconfigfile'); $this->assertCount(2, $files); settings_provider::delete_uploaded_config_file($this->quiz->cmid); $files = $fs->get_area_files($this->context->id, 'quizaccess_seb', 'filemanager_sebconfigfile'); // The '.' directory. $this->assertCount(1, $files); } /** * Test getting the file from the context module id file area. */ public function test_get_module_context_sebconfig_file(): void { $this->resetAfterTest(); $this->setAdminUser(); $this->course = $this->getDataGenerator()->create_course(); $this->quiz = $this->create_test_quiz($this->course, settings_provider::USE_SEB_CONFIG_MANUALLY); $this->context = \context_module::instance($this->quiz->cmid); $this->set_up_user_and_role(); $xml = file_get_contents(self::get_fixture_path(__NAMESPACE__, 'unencrypted.seb')); $draftitemid = $this->create_test_draftarea_file($xml); $fs = get_file_storage(); $files = $fs->get_area_files($this->context->id, 'quizaccess_seb', 'filemanager_sebconfigfile'); $this->assertCount(0, $files); settings_provider::save_filemanager_sebconfigfile_draftarea($draftitemid, $this->quiz->cmid); $settings = seb_quiz_settings::get_record(['quizid' => $this->quiz->id]); $settings->set('requiresafeexambrowser', settings_provider::USE_SEB_UPLOAD_CONFIG); $settings->save(); $file = settings_provider::get_module_context_sebconfig_file($this->quiz->cmid); $this->assertSame($file->get_content(), $xml); } /** * Test file manager options. */ public function test_get_filemanager_options(): void { $expected = [ 'subdirs' => 0, 'maxfiles' => 1, 'accepted_types' => ['.seb'] ]; $this->assertSame($expected, settings_provider::get_filemanager_options()); } /** * Test that users can or can not configure seb settings. */ public function test_can_configure_seb(): void { $this->resetAfterTest(); $this->course = $this->getDataGenerator()->create_course(); $this->quiz = $this->getDataGenerator()->create_module('quiz', ['course' => $this->course->id]); $this->context = \context_module::instance($this->quiz->cmid); $this->setAdminUser(); $this->assertTrue(settings_provider::can_configure_seb($this->context)); $this->set_up_user_and_role(); $this->assertFalse(settings_provider::can_configure_seb($this->context)); assign_capability('quizaccess/seb:manage_seb_requiresafeexambrowser', CAP_ALLOW, $this->roleid, $this->context->id); $this->assertTrue(settings_provider::can_configure_seb($this->context)); } /** * Test that users can or can not use seb template. */ public function test_can_use_seb_template(): void { $this->resetAfterTest(); $this->course = $this->getDataGenerator()->create_course(); $this->quiz = $this->getDataGenerator()->create_module('quiz', ['course' => $this->course->id]); $this->context = \context_module::instance($this->quiz->cmid); $this->setAdminUser(); $this->assertTrue(settings_provider::can_use_seb_template($this->context)); $this->set_up_user_and_role(); $this->assertFalse(settings_provider::can_use_seb_template($this->context)); assign_capability('quizaccess/seb:manage_seb_templateid', CAP_ALLOW, $this->roleid, $this->context->id); $this->assertTrue(settings_provider::can_use_seb_template($this->context)); } /** * Test that users can or can not upload seb config file. */ public function test_can_upload_seb_file(): void { $this->resetAfterTest(); $this->course = $this->getDataGenerator()->create_course(); $this->quiz = $this->getDataGenerator()->create_module('quiz', ['course' => $this->course->id]); $this->context = \context_module::instance($this->quiz->cmid); $this->setAdminUser(); $this->assertTrue(settings_provider::can_upload_seb_file($this->context)); $this->set_up_user_and_role(); $this->assertFalse(settings_provider::can_upload_seb_file($this->context)); assign_capability('quizaccess/seb:manage_filemanager_sebconfigfile', CAP_ALLOW, $this->roleid, $this->context->id); $this->assertTrue(settings_provider::can_upload_seb_file($this->context)); } /** * Test that users can or can not change Show Safe Exam Browser download button setting. */ public function test_can_change_seb_showsebdownloadlink(): void { $this->resetAfterTest(); $this->course = $this->getDataGenerator()->create_course(); $this->quiz = $this->getDataGenerator()->create_module('quiz', ['course' => $this->course->id]); $this->context = \context_module::instance($this->quiz->cmid); $this->setAdminUser(); $this->assertTrue(settings_provider::can_change_seb_showsebdownloadlink($this->context)); $this->set_up_user_and_role(); $this->assertFalse(settings_provider::can_change_seb_showsebdownloadlink($this->context)); assign_capability('quizaccess/seb:manage_seb_showsebdownloadlink', CAP_ALLOW, $this->roleid, $this->context->id); $this->assertTrue(settings_provider::can_change_seb_showsebdownloadlink($this->context)); } /** * Test that users can or can not change Allowed Browser Exam Keys setting. */ public function test_can_change_seb_allowedbrowserexamkeys(): void { $this->resetAfterTest(); $this->course = $this->getDataGenerator()->create_course(); $this->quiz = $this->getDataGenerator()->create_module('quiz', ['course' => $this->course->id]); $this->context = \context_module::instance($this->quiz->cmid); $this->setAdminUser(); $this->assertTrue(settings_provider::can_change_seb_allowedbrowserexamkeys($this->context)); $this->set_up_user_and_role(); $this->assertFalse(settings_provider::can_change_seb_allowedbrowserexamkeys($this->context)); assign_capability('quizaccess/seb:manage_seb_allowedbrowserexamkeys', CAP_ALLOW, $this->roleid, $this->context->id); $this->assertTrue(settings_provider::can_change_seb_allowedbrowserexamkeys($this->context)); } /** * Test that users can or can not Configure SEb manually * * @param string $settingcapability Setting capability to check manual option against. * * @dataProvider settings_capability_data_provider */ public function test_can_configure_manually($settingcapability): void { $this->resetAfterTest(); $this->course = $this->getDataGenerator()->create_course(); $this->quiz = $this->getDataGenerator()->create_module('quiz', ['course' => $this->course->id]); $this->context = \context_module::instance($this->quiz->cmid); $this->setAdminUser(); $this->assertTrue(settings_provider::can_configure_manually($this->context)); $this->set_up_user_and_role(); $this->assertFalse(settings_provider::can_configure_manually($this->context)); assign_capability('quizaccess/seb:manage_seb_configuremanually', CAP_ALLOW, $this->roleid, $this->context->id); $this->assertFalse(settings_provider::can_configure_manually($this->context)); assign_capability($settingcapability, CAP_ALLOW, $this->roleid, $this->context->id); $this->assertTrue(settings_provider::can_configure_manually($this->context)); } /** * Test that we can check if the seb settings are locked. */ public function test_is_seb_settings_locked(): void { $this->resetAfterTest(); $this->course = $this->getDataGenerator()->create_course(); $this->quiz = $this->create_test_quiz($this->course); $user = $this->getDataGenerator()->create_user(); $this->assertFalse(settings_provider::is_seb_settings_locked($this->quiz->id)); $this->attempt_quiz($this->quiz, $user); $this->assertTrue(settings_provider::is_seb_settings_locked($this->quiz->id)); } /** * Test that we can check identify conflicting permissions if set to use template. */ public function test_is_conflicting_permissions_for_manage_templates(): void { $this->resetAfterTest(); $this->setAdminUser(); $this->course = $this->getDataGenerator()->create_course(); $this->quiz = $this->create_test_quiz($this->course, settings_provider::USE_SEB_CONFIG_MANUALLY); $this->context = \context_module::instance($this->quiz->cmid); // Create a template. $template = $this->create_template(); $settings = seb_quiz_settings::get_record(['quizid' => $this->quiz->id]); $settings->set('templateid', $template->get('id')); $settings->set('requiresafeexambrowser', settings_provider::USE_SEB_TEMPLATE); $settings->save(); $this->assertFalse(settings_provider::is_conflicting_permissions($this->context)); $this->set_up_user_and_role(); $this->assertTrue(settings_provider::is_conflicting_permissions($this->context)); assign_capability('quizaccess/seb:manage_seb_templateid', CAP_ALLOW, $this->roleid, $this->context->id); $this->assertFalse(settings_provider::is_conflicting_permissions($this->context)); } /** * Test that we can check identify conflicting permissions if set to use own seb file. */ public function test_is_conflicting_permissions_for_upload_seb_file(): void { $this->resetAfterTest(); $this->setAdminUser(); $this->course = $this->getDataGenerator()->create_course(); $this->quiz = $this->create_test_quiz($this->course, settings_provider::USE_SEB_CONFIG_MANUALLY); $this->context = \context_module::instance($this->quiz->cmid); // Save file. $xml = file_get_contents(self::get_fixture_path(__NAMESPACE__, 'unencrypted.seb')); $draftitemid = $this->create_test_draftarea_file($xml); settings_provider::save_filemanager_sebconfigfile_draftarea($draftitemid, $this->quiz->cmid); $settings = seb_quiz_settings::get_record(['quizid' => $this->quiz->id]); $settings->set('requiresafeexambrowser', settings_provider::USE_SEB_UPLOAD_CONFIG); $settings->save(); $this->assertFalse(settings_provider::is_conflicting_permissions($this->context)); $this->set_up_user_and_role(); assign_capability('quizaccess/seb:manage_filemanager_sebconfigfile', CAP_ALLOW, $this->roleid, $this->context->id); $this->assertFalse(settings_provider::is_conflicting_permissions($this->context)); } /** * Test that we can check identify conflicting permissions if set to use own configure manually. * * @param string $settingcapability Setting capability to check manual option against. * * @dataProvider settings_capability_data_provider */ public function test_is_conflicting_permissions_for_configure_manually($settingcapability): void { $this->resetAfterTest(); $this->setAdminUser(); $this->course = $this->getDataGenerator()->create_course(); $this->quiz = $this->create_test_quiz($this->course, settings_provider::USE_SEB_CONFIG_MANUALLY); $this->context = \context_module::instance($this->quiz->cmid); $this->assertFalse(settings_provider::is_conflicting_permissions($this->context)); $this->set_up_user_and_role(); $this->assertTrue(settings_provider::is_conflicting_permissions($this->context)); assign_capability('quizaccess/seb:manage_seb_configuremanually', CAP_ALLOW, $this->roleid, $this->context->id); $this->assertTrue(settings_provider::is_conflicting_permissions($this->context)); assign_capability($settingcapability, CAP_ALLOW, $this->roleid, $this->context->id); $this->assertFalse(settings_provider::is_conflicting_permissions($this->context)); } /** * Test add_prefix helper method. */ public function test_add_prefix(): void { $this->assertEquals('seb_one', settings_provider::add_prefix('one')); $this->assertEquals('seb_two', settings_provider::add_prefix('seb_two')); $this->assertEquals('seb_seb_three', settings_provider::add_prefix('seb_seb_three')); $this->assertEquals('seb_', settings_provider::add_prefix('seb_')); $this->assertEquals('seb_', settings_provider::add_prefix('')); $this->assertEquals('seb_one_seb', settings_provider::add_prefix('one_seb')); } /** * Test filter_plugin_settings helper method. */ public function test_filter_plugin_settings(): void { $test = new \stdClass(); $test->one = 'one'; $test->seb_two = 'two'; $test->seb_seb_three = 'three'; $test->four = 'four'; $newsettings = (array)settings_provider::filter_plugin_settings($test); $this->assertFalse(key_exists('one', $newsettings)); $this->assertFalse(key_exists('four', $newsettings)); $this->assertCount(2, $newsettings); $this->assertEquals('two', $newsettings['two']); $this->assertEquals('three', $newsettings['seb_three']); } /** * Helper method to get a list of settings. * * @return \stdClass */ protected function get_settings() { $allsettings = new \stdClass(); $allsettings->seb_showsebdownloadlink = 0; $allsettings->seb_linkquitseb = 2; $allsettings->seb_userconfirmquit = 3; $allsettings->seb_allowuserquitseb = 4; $allsettings->seb_quitpassword = 5; $allsettings->seb_allowreloadinexam = 6; $allsettings->seb_showsebtaskbar = 7; $allsettings->seb_showreloadbutton = 8; $allsettings->seb_showtime = 9; $allsettings->seb_showkeyboardlayout = 10; $allsettings->seb_showwificontrol = 11; $allsettings->seb_enableaudiocontrol = 12; $allsettings->seb_muteonstartup = 13; $allsettings->seb_allowspellchecking = 14; $allsettings->seb_activateurlfiltering = 15; $allsettings->seb_filterembeddedcontent = 16; $allsettings->seb_expressionsallowed = 17; $allsettings->seb_regexallowed = 18; $allsettings->seb_expressionsblocked = 19; $allsettings->seb_regexblocked = 20; $allsettings->seb_templateid = 21; $allsettings->seb_allowedbrowserexamkeys = 22; $allsettings->seb_allowcapturecamera = 23; $allsettings->seb_allowcapturemicrophone = 24; return $allsettings; } /** * Helper method to assert results of filter_plugin_settings * * @param int $type Type of SEB usage. * @param array $notnulls A list of expected not null settings. */ protected function assert_filter_plugin_settings(int $type, array $notnulls) { $allsettings = $this->get_settings(); $allsettings->seb_requiresafeexambrowser = $type; $actual = settings_provider::filter_plugin_settings($allsettings); $expected = (array)$allsettings; foreach ($actual as $name => $value) { if (in_array($name, $notnulls)) { $this->assertEquals($expected['seb_' . $name], $value); } else { $this->assertNull($value); } } } /** * Test filter_plugin_settings method for no SEB case. */ public function test_filter_plugin_settings_for_no_seb(): void { $notnulls = ['requiresafeexambrowser']; $this->assert_filter_plugin_settings(settings_provider::USE_SEB_NO, $notnulls); } /** * Test filter_plugin_settings method for using uploaded config. */ public function test_filter_plugin_settings_for_uploaded_config(): void { $notnulls = ['requiresafeexambrowser', 'showsebdownloadlink', 'allowedbrowserexamkeys']; $this->assert_filter_plugin_settings(settings_provider::USE_SEB_UPLOAD_CONFIG, $notnulls); } /** * Test filter_plugin_settings method for using template. */ public function test_filter_plugin_settings_for_template(): void { $notnulls = ['requiresafeexambrowser', 'showsebdownloadlink', 'allowuserquitseb', 'quitpassword', 'templateid']; $this->assert_filter_plugin_settings(settings_provider::USE_SEB_TEMPLATE, $notnulls); } /** * Test filter_plugin_settings method for using client config. */ public function test_filter_plugin_settings_for_client_config(): void { $notnulls = ['requiresafeexambrowser', 'showsebdownloadlink', 'allowedbrowserexamkeys']; $this->assert_filter_plugin_settings(settings_provider::USE_SEB_CLIENT_CONFIG, $notnulls); } /** * Test filter_plugin_settings method for manually configured SEB. */ public function test_filter_plugin_settings_for_configure_manually(): void { $allsettings = $this->get_settings(); $allsettings->seb_requiresafeexambrowser = settings_provider::USE_SEB_CONFIG_MANUALLY; $actual = settings_provider::filter_plugin_settings($allsettings); // For manual it's easier to check nulls, as most of the settings are not null. $nulls = ['templateid', 'allowedbrowserexamkeys']; $expected = (array)$allsettings; foreach ($actual as $name => $value) { if (in_array($name, $nulls)) { $this->assertNull($value); } else { $this->assertEquals($expected['seb_' . $name], $value); } } } /** * Test settings map. */ public function test_get_seb_settings_map(): void { $expected = [ settings_provider::USE_SEB_NO => [ ], settings_provider::USE_SEB_CONFIG_MANUALLY => [ 'seb_showsebdownloadlink' => [], 'seb_linkquitseb' => [], 'seb_userconfirmquit' => [], 'seb_allowuserquitseb' => [ 'seb_quitpassword' => [] ], 'seb_allowreloadinexam' => [], 'seb_showsebtaskbar' => [ 'seb_showreloadbutton' => [], 'seb_showtime' => [], 'seb_showkeyboardlayout' => [], 'seb_showwificontrol' => [], ], 'seb_enableaudiocontrol' => [ 'seb_muteonstartup' => [], ], 'seb_allowcapturecamera' => [], 'seb_allowcapturemicrophone' => [], 'seb_allowspellchecking' => [], 'seb_activateurlfiltering' => [ 'seb_filterembeddedcontent' => [], 'seb_expressionsallowed' => [], 'seb_regexallowed' => [], 'seb_expressionsblocked' => [], 'seb_regexblocked' => [], ], ], settings_provider::USE_SEB_TEMPLATE => [ 'seb_templateid' => [], 'seb_showsebdownloadlink' => [], 'seb_allowuserquitseb' => [ 'seb_quitpassword' => [], ], ], settings_provider::USE_SEB_UPLOAD_CONFIG => [ 'filemanager_sebconfigfile' => [], 'seb_showsebdownloadlink' => [], 'seb_allowedbrowserexamkeys' => [], ], settings_provider::USE_SEB_CLIENT_CONFIG => [ 'seb_showsebdownloadlink' => [], 'seb_allowedbrowserexamkeys' => [], ], ]; $this->assertEquals($expected, settings_provider::get_seb_settings_map()); } } PK9B\YdII(accessrule/seb/tests/config_key_test.phpnu[. namespace quizaccess_seb; /** * PHPUnit Tests for config_key class. * * @package quizaccess_seb * @author Andrew Madden * @copyright 2020 Catalyst IT * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @covers \quizaccess_seb\config_key */ final class config_key_test extends \advanced_testcase { /** * Test that trying to generate the hash key with bad xml will result in an error. */ public function test_config_key_not_generated_with_bad_xml(): void { $this->expectException(\invalid_parameter_exception::class); $this->expectExceptionMessage("Invalid a PList XML string, representing SEB config"); config_key::generate("get_hash(); $this->assertEquals('4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945', $hash); } /** * Test config key hash is derived correctly by Moodle. * * @param string $config The SEB config file name. * @param string $hash The correct config key hash for this file. * * @dataProvider real_ck_hash_provider */ public function test_config_key_hash_is_derived_correctly($config, $hash): void { $xml = file_get_contents(self::get_fixture_path(__NAMESPACE__, $config)); $derivedhash = config_key::generate($xml)->get_hash(); $this->assertEquals($hash, $derivedhash); } /** * Check that the Config Key hash is not altered if the originatorVersion is present in the XML or not. */ public function test_presence_of_originator_version_does_not_effect_hash(): void { $xmlwithoriginatorversion = file_get_contents(self::get_fixture_path(__NAMESPACE__, 'simpleunencrypted.seb')); $xmlwithoutoriginatorversion = file_get_contents(self::get_fixture_path(__NAMESPACE__, 'simpleunencryptedwithoutoriginator.seb')); $hashwithorigver = config_key::generate($xmlwithoriginatorversion)->get_hash(); $hashwithoutorigver = config_key::generate($xmlwithoutoriginatorversion)->get_hash(); $this->assertEquals($hashwithorigver, $hashwithoutorigver); } /** * Provide a seb file, the expected Config Key and a password if encrypted. * * @return array */ public static function real_ck_hash_provider(): array { return [ 'unencrypted_mac2.1.4' => ['unencrypted_mac_001.seb', '4fa9af8ec8759eb7c680752ef4ee5eaf1a860628608fccae2715d519849f9292', ''], 'unencrypted_win2.2.3' => ['unencrypted_win_223.seb', '2534e4e9f3188f9f9133bf7cf7b4c5d898292bbd7e8d0230f39d1176636a1431', ''], ]; } } PK9B\:s s ,accessrule/seb/tests/link_generator_test.phpnu[. namespace quizaccess_seb; /** * PHPUnit tests for link_generator. * * @package quizaccess_seb * @author Andrew Madden * @copyright 2020 Catalyst IT * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class link_generator_test extends \advanced_testcase { /** * Called before every test. */ public function setUp(): void { parent::setUp(); $this->resetAfterTest(); } /** * Test that a http link is generated correctly. */ public function test_http_link_generated(): void { $course = $this->getDataGenerator()->create_course(); $quiz = $this->getDataGenerator()->create_module('quiz', ['course' => $course->id]); $this->assertEquals( "http://www.example.com/moodle/mod/quiz/accessrule/seb/config.php?cmid=$quiz->cmid", link_generator::get_link($quiz->cmid, false, false)); } /** * Test that a http link is generated correctly. */ public function test_https_link_generated(): void { $course = $this->getDataGenerator()->create_course(); $quiz = $this->getDataGenerator()->create_module('quiz', ['course' => $course->id]); $this->assertEquals( "https://www.example.com/moodle/mod/quiz/accessrule/seb/config.php?cmid=$quiz->cmid", link_generator::get_link($quiz->cmid, false)); } /** * Test that a seb link is generated correctly. */ public function test_seb_link_generated(): void { $course = $this->getDataGenerator()->create_course(); $quiz = $this->getDataGenerator()->create_module('quiz', ['course' => $course->id]); $this->assertEquals( "seb://www.example.com/moodle/mod/quiz/accessrule/seb/config.php?cmid=$quiz->cmid", link_generator::get_link($quiz->cmid, true, false)); } /** * Test that a sebs link is generated correctly. */ public function test_sebs_link_generated(): void { $course = $this->getDataGenerator()->create_course(); $quiz = $this->getDataGenerator()->create_module('quiz', ['course' => $course->id]); $this->assertEquals( "sebs://www.example.com/moodle/mod/quiz/accessrule/seb/config.php?cmid=$quiz->cmid", link_generator::get_link($quiz->cmid, true)); } /** * Test that link_generator can't not be instantiated with fake course module. */ public function test_course_module_does_not_exist(): void { $this->expectException(\dml_exception::class); $this->expectExceptionMessageMatches("/^Can't find data record in database.*/"); $generator = link_generator::get_link(123456, false); } } PK9B\Kp*uPuP+accessrule/seb/tests/property_list_test.phpnu[. namespace quizaccess_seb; /** * PHPUnit for property_list class. * * @package quizaccess_seb * @author Andrew Madden * @copyright 2020 Catalyst IT * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class property_list_test extends \advanced_testcase { /** * Test that an empty PList with a root dictionary is created. */ public function test_create_empty_plist(): void { $emptyplist = new property_list(); $xml = trim($emptyplist->to_xml()); $this->assertEquals(' ', $xml); } /** * Test that a Plist is constructed from an XML string. */ public function test_construct_plist_from_xml(): void { $xml = $this->get_plist_xml_header() . "testKey" . "testValue" . $this->get_plist_xml_footer(); $plist = new property_list($xml); $generatedxml = trim($plist->to_xml()); $this->assertEquals(" testKeytestValue", $generatedxml); } /** * Test that an element can be added to the root dictionary. */ public function test_add_element_to_root(): void { $plist = new property_list(); $newelement = new \CFPropertyList\CFString('testValue'); $plist->add_element_to_root('testKey', $newelement); $generatedxml = trim($plist->to_xml()); $this->assertEquals(" testKeytestValue", $generatedxml); } /** * Test that an element's value can be retrieved. */ public function test_get_element_value(): void { $xml = $this->get_plist_xml_header() . "testKey" . "testValue" . $this->get_plist_xml_footer(); $plist = new property_list($xml); $this->assertEquals('testValue', $plist->get_element_value('testKey')); } /** * Test that an element's value can be retrieved. */ public function test_get_element_value_if_not_exists(): void { $plist = new property_list(); $this->assertEmpty($plist->get_element_value('testKey')); } /** * Test an element's value can be retrieved if it is an array. */ public function test_get_element_value_if_array(): void { $xml = $this->get_plist_xml_header() . "testDict" . "" . "testKey" . "testValue" . "" . $this->get_plist_xml_footer(); $plist = new property_list($xml); $this->assertEquals(['testKey' => 'testValue'], $plist->get_element_value('testDict')); } /** * Test that a element's value can be updated that is not an array or dictionary. * * @param string $xml XML to create PList. * @param string $key Key of element to try and update. * @param mixed $value Value to try to update with. * * @dataProvider good_update_data_provider */ public function test_updating_element_value($xml, $key, $value): void { $xml = $this->get_plist_xml_header() . $xml . $this->get_plist_xml_footer(); $plist = new property_list($xml); $plist->update_element_value($key, $value); $this->assertEquals($value, $plist->get_element_value($key)); } /** * Test that a element's value can be updated that is not an array or dictionary. * * @param string $xml XML to create PList. * @param string $key Key of element to try and update. * @param mixed $value Bad value to try to update with. * @param mixed $expected Expected value of element after update is called. * @param string $exceptionmessage Message of exception expected to be thrown. * @dataProvider bad_update_data_provider */ public function test_updating_element_value_with_bad_data(string $xml, string $key, $value, $expected, $exceptionmessage): void { $xml = $this->get_plist_xml_header() . $xml . $this->get_plist_xml_footer(); $plist = new property_list($xml); $this->expectException(\invalid_parameter_exception::class); $this->expectExceptionMessage($exceptionmessage); $plist->update_element_value($key, $value); $plistarray = json_decode($plist->to_json()); // Export elements. $this->assertEquals($expected, $plistarray->$key); } /** * Test that a dictionary can have it's value (array) updated. */ public function test_updating_element_array_if_dictionary(): void { $xml = $this->get_plist_xml_header() . "testDict" . "" . "testKey" . "testValue" . "" . $this->get_plist_xml_footer(); $plist = new property_list($xml); $plist->update_element_array('testDict', ['newKey' => new \CFPropertyList\CFString('newValue')]); $this->assertEquals(['newKey' => 'newValue'], $plist->get_element_value('testDict')); } /** * Test that a dictionary can have it's value (array) updated. */ public function test_updating_element_array_if_dictionary_with_bad_data(): void { $xml = $this->get_plist_xml_header() . "testDict" . "" . "testKey" . "testValue" . "" . $this->get_plist_xml_footer(); $plist = new property_list($xml); $this->expectException(\invalid_parameter_exception::class); $this->expectExceptionMessage('New array must only contain CFType objects.'); $plist->update_element_array('testDict', [false]); $this->assertEquals(['testKey' => 'testValue'], $plist->get_element_value('testDict')); $this->assertDebuggingCalled('property_list: If updating an array in PList, it must only contain CFType objects.', DEBUG_DEVELOPER); } /** * Test that an element can be deleted. */ public function test_delete_element(): void { $xml = $this->get_plist_xml_header() . "testKey" . "testValue" . $this->get_plist_xml_footer(); $plist = new property_list($xml); $plist->delete_element('testKey'); $generatedxml = trim($plist->to_xml()); $this->assertEquals(" ", $generatedxml); } /** * Test that an element can be deleted. */ public function test_delete_element_if_not_exists(): void { $xml = $this->get_plist_xml_header() . "testKey" . "testValue" . $this->get_plist_xml_footer(); $plist = new property_list($xml); $plist->delete_element('nonExistentKey'); $generatedxml = trim($plist->to_xml()); // The xml should be unaltered. $this->assertEquals(" testKeytestValue", $generatedxml); } /** * Test that json is exported correctly according to SEB Config Key requirements. * * @param string $xml PList XML used to generate CFPropertyList. * @param string $expectedjson Expected JSON output. * * @dataProvider json_data_provider */ public function test_export_to_json($xml, $expectedjson): void { $xml = $this->get_plist_xml_header() . $xml . $this->get_plist_xml_footer(); $plist = new property_list($xml); $generatedjson = $plist->to_json(); $this->assertEquals($expectedjson, $generatedjson); } /** * Test that the xml is exported to JSON from a real SEB config file. Expected JSON extracted from SEB logs. */ public function test_export_to_json_full_file(): void { $xml = file_get_contents(self::get_fixture_path(__NAMESPACE__, 'unencrypted_mac_001.seb')); $plist = new property_list($xml); $plist->delete_element('originatorVersion'); // JSON should not contain originatorVersion key. $generatedjson = $plist->to_json(); $json = trim(file_get_contents(self::get_fixture_path(__NAMESPACE__, 'JSON_unencrypted_mac_001.txt'))); $this->assertEquals($json, $generatedjson); } /** * Test the set_or_update_value function. */ public function test_set_or_update_value(): void { $plist = new property_list(); $this->assertEmpty($plist->get_element_value('string')); $this->assertEmpty($plist->get_element_value('bool')); $this->assertEmpty($plist->get_element_value('number')); // Setting values. $plist->set_or_update_value('string', new \CFPropertyList\CFString('initial string')); $plist->set_or_update_value('bool', new \CFPropertyList\CFBoolean(true)); $plist->set_or_update_value('number', new \CFPropertyList\CFNumber('10')); $this->assertEquals('initial string', $plist->get_element_value('string')); $this->assertEquals(true, $plist->get_element_value('bool')); $this->assertEquals(10, $plist->get_element_value('number')); // Updating values. $plist->set_or_update_value('string', new \CFPropertyList\CFString('new string')); $plist->set_or_update_value('bool', new \CFPropertyList\CFBoolean(false)); $plist->set_or_update_value('number', new \CFPropertyList\CFNumber('42')); $this->assertEquals('new string', $plist->get_element_value('string')); $this->assertEquals(false, $plist->get_element_value('bool')); $this->assertEquals(42, $plist->get_element_value('number')); // Type exception. $this->expectException(\TypeError::class); $plist->set_or_update_value('someKey', 'We really need to pass in CFTypes here'); } /** * Get a valid PList header. Must also use footer. * * @return string */ private function get_plist_xml_header(): string { return "\n" . "\n" . "\n" . " "; } /** * Get a valid PList footer. Must also use header. * * @return string */ private function get_plist_xml_footer(): string { return " \n" . ""; } /** * Data provider for good data on update. * * @return array Array with test data. */ public static function good_update_data_provider(): array { return [ 'Update string' => ['testKeytestValue', 'testKey', 'newValue'], 'Update bool' => ['testKey', 'testKey', false], 'Update number' => ['testKey888', 'testKey', 123.4], ]; } /** * Data provider for bad data on update. * * @return array Array with test data. */ public static function bad_update_data_provider(): array { return [ 'Update string with bool' => ['testKeytestValue', 'testKey', true, 'testValue', 'Invalid parameter value detected (Only string, number and boolean elements can be updated, ' . 'or value type does not match element type: CFPropertyList\CFString'], 'Update string with number' => ['testKeytestValue', 'testKey', 999, 'testValue', 'Invalid parameter value detected (Only string, number and boolean elements can be updated, ' . 'or value type does not match element type: CFPropertyList\CFString'], 'Update string with null' => ['testKeytestValue', 'testKey', null, 'testValue', 'Invalid parameter value detected (Only string, number and boolean elements can be updated, ' . 'or value type does not match element type: CFPropertyList\CFString'], 'Update string with array' => ['testKeytestValue', 'testKey', ['arrayValue'], 'testValue', 'Use update_element_array to update a collection.'], 'Update bool with string' => ['testKey', 'testKey', 'testValue', true, 'Invalid parameter value detected (Only string, number and boolean elements can be updated, ' . 'or value type does not match element type: CFPropertyList\CFBool'], 'Update bool with number' => ['testKey', 'testKey', 999, true, 'Invalid parameter value detected (Only string, number and boolean elements can be updated, ' . 'or value type does not match element type: CFPropertyList\CFBool'], 'Update bool with null' => ['testKey', 'testKey', null, true, 'Invalid parameter value detected (Only string, number and boolean elements can be updated, ' . 'or value type does not match element type: CFPropertyList\CFBool'], 'Update bool with array' => ['testKey', 'testKey', ['testValue'], true, 'Use update_element_array to update a collection.'], 'Update number with string' => ['testKey888', 'testKey', 'string', 888, 'Invalid parameter value detected (Only string, number and boolean elements can be updated, ' . 'or value type does not match element type: CFPropertyList\CFNumber'], 'Update number with bool' => ['testKey888', 'testKey', true, 888, 'Invalid parameter value detected (Only string, number and boolean elements can be updated, ' . 'or value type does not match element type: CFPropertyList\CFNumber'], 'Update number with null' => ['testKey888', 'testKey', null, 888, 'Invalid parameter value detected (Only string, number and boolean elements can be updated, ' . 'or value type does not match element type: CFPropertyList\CFNumber'], 'Update number with array' => ['testKey888', 'testKey', ['testValue'], 888, 'Use update_element_array to update a collection.'], 'Update date with string' => ['testKey1940-10-09T22:13:56Z', 'testKey', 'string', '1940-10-10T06:13:56+08:00', 'Invalid parameter value detected (Only string, number and boolean elements can be updated, ' . 'or value type does not match element type: CFPropertyList\CFDate'], 'Update data with number' => ['testKeytestData', 'testKey', 789, 'testData', 'Invalid parameter value detected (Only string, number and boolean elements can be updated, ' . 'or value type does not match element type: CFPropertyList\CFData'], ]; } /** * Data provider for expected JSON from PList. * * Examples extracted from requirements listed in SEB Config Key documents. * https://safeexambrowser.org/developer/seb-config-key.html * * 1. Date should be in ISO 8601 format. * 2. Data should be base 64 encoded. * 3. String should be UTF-8 encoded. * 4, 5, 6, 7. No requirements for bools, arrays or dicts. * 8. Empty dicts should not be included. * 9. JSON key ordering should be case insensitive, and use string ordering. * 10. URL forward slashes should not be escaped. * * @return array */ public static function json_data_provider(): array { $data = "blahblah"; $base64data = base64_encode($data); return [ 'date' => ["date1940-10-09T22:13:56Z", "{\"date\":\"1940-10-09T22:13:56+00:00\"}"], 'data' => ["data$base64data", "{\"data\":\"$base64data\"}"], 'string' => ["stringhello wörld", "{\"string\":\"hello wörld\"}"], 'string with 1 backslash' => ["stringws:\localhost", "{\"string\":\"ws:\localhost\"}"], 'string with 2 backslashes' => ["stringws:\\localhost", '{"string":"ws:\\localhost"}'], 'string with 3 backslashes' => ["stringws:\\\localhost", '{"string":"ws:\\\localhost"}'], 'string with 4 backslashes' => ["stringws:\\\\localhost", '{"string":"ws:\\\\localhost"}'], 'string with 5 backslashes' => ["stringws:\\\\\localhost", '{"string":"ws:\\\\\localhost"}'], 'bool' => ["bool", "{\"bool\":true}"], 'array' => ["arrayarrayboolarraybool2" , "{\"array\":[false,true]}"], 'empty array' => ["boolarray" , "{\"array\":[],\"bool\":true}"], 'dict' => ["dictdictbooldictbool2" , "{\"dict\":{\"dictbool\":false,\"dictbool2\":true}}"], 'empty dict' => ["boolemptydict", "{\"bool\":true}"], 'unordered elements' => ["testKey" . "testValue" . "allowWLAN" . "testValue2" . "allowWlan" . "testValue3" , "{\"allowWlan\":\"testValue3\",\"allowWLAN\":\"testValue2\",\"testKey\":\"testValue\"}"], 'url' => ["urlhttp://test.com", "{\"url\":\"http://test.com\"}"], 'assoc dict' => ["dictbananaapple", "{\"dict\":{\"apple\":true,\"banana\":false}}"], 'seq array' => ["array12 3456 78910", "{\"array\":[false,true,true,true,true,true,true,true,true,true]}"], ]; } } PK9B\էDw*accessrule/seb/tests/event/events_test.phpnu[. namespace quizaccess_seb\event; use mod_quiz\quiz_settings; defined('MOODLE_INTERNAL') || die(); require_once(__DIR__ . '/..//test_helper_trait.php'); /** * PHPUnit tests for all plugin events. * * @package quizaccess_seb * @author Andrew Madden * @copyright 2020 Catalyst IT * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class events_test extends \advanced_testcase { use \quizaccess_seb_test_helper_trait; /** * Called before every test. */ public function setUp(): void { parent::setUp(); $this->resetAfterTest(); $this->course = $this->getDataGenerator()->create_course(); } /** * Test creating the access_prevented event. * * @covers \quizaccess_seb\event\access_prevented */ public function test_event_access_prevented(): void { $this->resetAfterTest(); $this->setAdminUser(); $quiz = $this->create_test_quiz($this->course, \quizaccess_seb\settings_provider::USE_SEB_CONFIG_MANUALLY); $accessmanager = new \quizaccess_seb\seb_access_manager(new quiz_settings($quiz, get_coursemodule_from_id('quiz', $quiz->cmid), $this->course)); // Set up event with data. $user = $this->getDataGenerator()->create_user(); $this->setUser($user); $_SERVER['HTTP_X_SAFEEXAMBROWSER_CONFIGKEYHASH'] = 'configkey'; $_SERVER['HTTP_X_SAFEEXAMBROWSER_REQUESTHASH'] = 'browserexamkey'; $event = \quizaccess_seb\event\access_prevented::create_strict($accessmanager, 'Because I said so.'); // Create an event sink, trigger event and retrieve event. $sink = $this->redirectEvents(); $event->trigger(); $events = $sink->get_events(); $this->assertEquals(1, count($events)); $event = reset($events); $expectedconfigkey = $accessmanager->get_valid_config_key(); // Test that the event data is as expected. $this->assertInstanceOf('\quizaccess_seb\event\access_prevented', $event); $this->assertEquals('Quiz access was prevented', $event->get_name()); $this->assertEquals( "The user with id '$user->id' has been prevented from accessing quiz with id '$quiz->id' by the " . "Safe Exam Browser access plugin. The reason was 'Because I said so.'. " . "Expected config key: '$expectedconfigkey'. " . "Received config key: 'configkey'. Received browser exam key: 'browserexamkey'.", $event->get_description()); $this->assertEquals(\context_module::instance($quiz->cmid), $event->get_context()); $this->assertEquals($user->id, $event->userid); $this->assertEquals($quiz->id, $event->objectid); $this->assertEquals($this->course->id, $event->courseid); $this->assertEquals('Because I said so.', $event->other['reason']); $this->assertEquals($expectedconfigkey, $event->other['savedconfigkey']); $this->assertEquals('configkey', $event->other['receivedconfigkey']); $this->assertEquals('browserexamkey', $event->other['receivedbrowserexamkey']); } /** * Test creating the access_prevented event with provided SEB keys. * * @covers \quizaccess_seb\event\access_prevented */ public function test_event_access_prevented_with_keys(): void { $this->resetAfterTest(); $this->setAdminUser(); $quiz = $this->create_test_quiz($this->course, \quizaccess_seb\settings_provider::USE_SEB_CONFIG_MANUALLY); $accessmanager = new \quizaccess_seb\seb_access_manager(new quiz_settings($quiz, get_coursemodule_from_id('quiz', $quiz->cmid), $this->course)); // Set up event with data. $user = $this->getDataGenerator()->create_user(); $this->setUser($user); $event = \quizaccess_seb\event\access_prevented::create_strict($accessmanager, 'Because I said so.', 'configkey', 'browserexamkey'); // Create an event sink, trigger event and retrieve event. $sink = $this->redirectEvents(); $event->trigger(); $events = $sink->get_events(); $this->assertEquals(1, count($events)); $event = reset($events); $expectedconfigkey = $accessmanager->get_valid_config_key(); // Test that the event data is as expected. $this->assertInstanceOf('\quizaccess_seb\event\access_prevented', $event); $this->assertEquals('Quiz access was prevented', $event->get_name()); $this->assertEquals( "The user with id '$user->id' has been prevented from accessing quiz with id '$quiz->id' by the " . "Safe Exam Browser access plugin. The reason was 'Because I said so.'. " . "Expected config key: '$expectedconfigkey'. " . "Received config key: 'configkey'. Received browser exam key: 'browserexamkey'.", $event->get_description()); $this->assertEquals(\context_module::instance($quiz->cmid), $event->get_context()); $this->assertEquals($user->id, $event->userid); $this->assertEquals($quiz->id, $event->objectid); $this->assertEquals($this->course->id, $event->courseid); $this->assertEquals('Because I said so.', $event->other['reason']); $this->assertEquals($expectedconfigkey, $event->other['savedconfigkey']); $this->assertEquals('configkey', $event->other['receivedconfigkey']); $this->assertEquals('browserexamkey', $event->other['receivedbrowserexamkey']); } /** * Test creating the template_created event. * * @covers \quizaccess_seb\event\template_created */ public function test_event_create_template(): void { $this->resetAfterTest(); // Set up event with data. $user = $this->getDataGenerator()->create_user(); $this->setUser($user); $template = $this->create_template(); $event = \quizaccess_seb\event\template_created::create_strict( $template, \context_system::instance()); // Create an event sink, trigger event and retrieve event. $sink = $this->redirectEvents(); $event->trigger(); $events = $sink->get_events(); $this->assertEquals(1, count($events)); $event = reset($events); // Test that the event data is as expected. $this->assertInstanceOf('\quizaccess_seb\event\template_created', $event); $this->assertEquals('SEB template was created', $event->get_name()); $this->assertEquals( "The user with id '$user->id' has created a template with id '{$template->get('id')}'.", $event->get_description() ); $this->assertEquals(\context_system::instance(), $event->get_context()); $this->assertEquals($user->id, $event->userid); $this->assertEquals($template->get('id'), $event->objectid); } } PK9B\E; /accessrule/seb/tests/behat/seb_settings.featurenu[@mod_quiz @quizaccess @quizaccess_seb Feature: SEB settings in quiz access rule Background: Given the following "users" exist: | username | firstname | lastname | email | | student1 | Student | 1 | student1@example.com | And the following "courses" exist: | fullname | shortname | | Course 1 | C1 | And the following "course enrolments" exist: | user | course | role | | student1 | C1 | student | Scenario Outline: Require the use of Safe Exam Browser set to Yes – Configure manually shows message to students Given the following "activities" exist: | activity | course | name | seb_requiresafeexambrowser | idnumber | | quiz | C1 | Quiz 1 | | 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 | Reading | Can you read this? | And quiz "Quiz 1" contains the following questions: | question | page | | Reading | 1 | When I am on the "Quiz 1" "quiz activity" page logged in as student1 Then I see "This quiz has been configured so that students may only attempt it using the Safe Exam Browser." And "Attempt quiz" "button" exist Examples: | sebsetting | messagevisibility | attemptbuttonvisibility | | 0 | should not | should | | 1 | should | should not | Scenario Outline: Show Safe Exam Browser download button setting controls visibility of download link Given the following "activities" exist: | activity | course | name | idnumber | seb_requiresafeexambrowser | seb_showsebdownloadlink | | quiz | C1 | Quiz 1 | quiz1 | 1 | | 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 | Reading | Can you read this? | And quiz "Quiz 1" contains the following questions: | question | page | | Reading | 1 | When I am on the "Quiz 1" "quiz activity" page logged in as student1 Then "Download Safe Exam Browser" "link" exist And I should see "This quiz has been configured so that students may only attempt it using the Safe Exam Browser." Examples: | seb_showsebdownloadlink | downloadseblinkvisibility | | 0 | should not | | 1 | should | PK9B\Aaccessrule/seb/tests/behat/secure_layout_activity_heading.featurenu[@mod_quiz @quizaccess @quizaccess_seb Feature: View the activity header when Safe Exam Browser is required In order to correctly identify the quiz when Safe Exam Browser is required As a student I need to be able to see the quiz information in the activity header Background: Given the following "users" exist: | username | firstname | lastname | email | | student1 | Sam1 | Student1 | student1@example.com | And the following "courses" exist: | fullname | shortname | category | | Course 1 | C1 | 0 | And the following "course enrolments" exist: | user | course | role | | student1 | C1 | student | And the following "question categories" exist: | contextlevel | reference | name | | Course | C1 | Test questions | And the following "questions" exist: | questioncategory | qtype | name | questiontext | | Test questions | truefalse | TF1 | TF question | And the following "activity" exists: | activity | quiz | | course | C1 | | idnumber | 00001 | | name | Test quiz name | | intro | Test quiz description | | seb_requiresafeexambrowser | 1 | | grade | 10 | And quiz "Test quiz name" contains the following questions: | question | page | | TF1 | 1 | Scenario: Quiz description is displayed when Safe Exam Browser is required When I am on the "Test quiz name" "quiz activity" page logged in as student1 Then I should see "Launch Safe Exam Browser" And "Test quiz name" "heading" should exist And I should see "Test quiz description" PK9B\277,accessrule/seb/tests/behat/edit_form.featurenu[@javascript @mod_quiz @quizaccess @quizaccess_seb Feature: Safe Exam Browser settings in quiz edit form Background: Given the following "courses" exist: | fullname | shortname | | Course 1 | C1 | And the following "activities" exist: | activity | course | section | name | | quiz | C1 | 1 | Quiz 1 | | quiz | C1 | 1 | Quiz 2 | Scenario: Quiz setting "Require the use of Safe Exam Browser" has all types, except "Use an existing template". When I am on the "Quiz 1" "quiz activity editing" page logged in as admin And I expand all fieldsets And the "Require the use of Safe Exam Browser" select box should contain "Yes – Configure manually" And the "Require the use of Safe Exam Browser" select box should not contain "Yes – Use an existing template" And the "Require the use of Safe Exam Browser" select box should contain "Yes – Upload my own config" And the "Require the use of Safe Exam Browser" select box should contain "Yes – Use SEB client config" And the field "Require the use of Safe Exam Browser" matches value "No" Scenario: Quiz setting "Require the use of Safe Exam Browser" has all types if at least one template has been added. Given the following "quizaccess_seb > seb templates" exist: | name | | Template 1 | When I am on the "Quiz 1" "quiz activity editing" page logged in as admin And I expand all fieldsets And the "Require the use of Safe Exam Browser" select box should contain "Yes – Configure manually" And the "Require the use of Safe Exam Browser" select box should contain "Yes – Use an existing template" And the "Require the use of Safe Exam Browser" select box should contain "Yes – Upload my own config" And the "Require the use of Safe Exam Browser" select box should contain "Yes – Use SEB client config" And the field "Require the use of Safe Exam Browser" matches value "No" Scenario: Quiz can be edited without capability to select SEB template Given the following "permission override" exists: | role | editingteacher | | capability | quizaccess/seb:manage_seb_templateid | | permission | Prevent | | contextlevel | System | | reference | | And the following "user" exists: | username | teacher | | firstname | Teacher | | lastname | One | And the following "course enrolment" exists: | user | teacher | | course | C1 | | role | editingteacher | And I log in as "teacher" # Create the quiz. When I add a quiz activity to course "Course 1" section "0" and I fill the form with: | Name | My quiz | Then I should not see "New Quiz" # Edit the quiz. And I am on the "My quiz" "quiz activity editing" page And I set the field "Name" to "My quiz edited" And I press "Save and return to course" And I should not see "Edit settings" Scenario: SEB settings if using No SEB Given the following "quizaccess_seb > seb templates" exist: | name | | Template 1 | And I am on the "Quiz 1" "quiz activity editing" page logged in as admin And I expand all fieldsets And I set the field "Require the use of Safe Exam Browser" to "No" Then I should not see "Upload Safe Exam Browser config file" Then I should not see "Safe Exam Browser config template" Then I should not see "Template 1" Then I should not see "Show Safe Exam Browser download button" Then I should not see "Enable quitting of SEB" Then I should not see "Quit password" Then I should not see "Allowed browser exam keys" Then I should not see "Show Exit Safe Exam Browser button, configured with this quit link" Then I should not see "Ask user to confirm quitting" Then I should not see "Enable reload in exam" Then I should not see "Show SEB task bar" Then I should not see "Show reload button" Then I should not see "Show time" Then I should not see "Show keyboard layout" Then I should not see "Show Wi-Fi control" Then I should not see "Enable audio controls" Then I should not see "Mute on startup" Then I should not see "Allow browser access to camera" Then I should not see "Allow browser access to microphone" Then I should not see "Enable spell checking" Then I should not see "Enable URL filtering" Then I should not see "Filter also embedded content" Then I should not see "Expressions allowed" Then I should not see "Regex allowed" Then I should not see "Expressions blocked" Then I should not see "Regex blocked" Scenario: SEB settings if using Use SEB client config Given the following "quizaccess_seb > seb templates" exist: | name | | Template 1 | And I am on the "Quiz 1" "quiz activity editing" page logged in as admin And I expand all fieldsets And I set the field "Require the use of Safe Exam Browser" to "Yes – Use SEB client config" Then I should see "Show Safe Exam Browser download button" Then I should see "Allowed browser exam keys" Then I should not see "Upload Safe Exam Browser config file" Then I should not see "Safe Exam Browser config template" Then I should not see "Template 1" Then I should not see "Enable quitting of SEB" Then I should not see "Quit password" Then I should not see "Show Exit Safe Exam Browser button, configured with this quit link" Then I should not see "Ask user to confirm quitting" Then I should not see "Enable reload in exam" Then I should not see "Show SEB task bar" Then I should not see "Show reload button" Then I should not see "Show time" Then I should not see "Show keyboard layout" Then I should not see "Show Wi-Fi control" Then I should not see "Enable audio controls" Then I should not see "Mute on startup" Then I should not see "Allow browser access to camera" Then I should not see "Allow browser access to microphone" Then I should not see "Enable spell checking" Then I should not see "Enable URL filtering" Then I should not see "Filter also embedded content" Then I should not see "Expressions allowed" Then I should not see "Regex allowed" Then I should not see "Expressions blocked" Then I should not see "Regex blocked" Scenario: SEB settings if using Upload my own config Given the following "quizaccess_seb > seb templates" exist: | name | | Template 1 | And I am on the "Quiz 1" "quiz activity editing" page logged in as admin And I expand all fieldsets And I set the field "Require the use of Safe Exam Browser" to "Yes – Upload my own config" Then I should see "Upload Safe Exam Browser config file" Then I should see "Show Safe Exam Browser download button" Then I should not see "Enable quitting of SEB" Then I should not see "Quit password" Then I should see "Allowed browser exam keys" Then I should not see "Show Exit Safe Exam Browser button, configured with this quit link" Then I should not see "Ask user to confirm quitting" Then I should not see "Enable reload in exam" Then I should not see "Show SEB task bar" Then I should not see "Show reload button" Then I should not see "Show time" Then I should not see "Show keyboard layout" Then I should not see "Show Wi-Fi control" Then I should not see "Enable audio controls" Then I should not see "Mute on startup" Then I should not see "Allow browser access to camera" Then I should not see "Allow browser access to microphone" Then I should not see "Enable spell checking" Then I should not see "Enable URL filtering" Then I should not see "Filter also embedded content" Then I should not see "Expressions allowed" Then I should not see "Regex allowed" Then I should not see "Expressions blocked" Then I should not see "Regex blocked" Then I should not see "Safe Exam Browser config template" Then I should not see "Template 1" Scenario: SEB settings if using Use an existing template Given the following "quizaccess_seb > seb templates" exist: | name | | Template 1 | And I am on the "Quiz 1" "quiz activity editing" page logged in as admin And I expand all fieldsets And I set the field "Require the use of Safe Exam Browser" to "Yes – Use an existing template" Then I should see "Safe Exam Browser config template" Then I should see "Template 1" Then I should see "Show Safe Exam Browser download button" Then I should see "Enable quitting of SEB" Then I should see "Quit password" Then I should not see "Allowed browser exam keys" Then I should not see "Upload Safe Exam Browser config file" Then I should not see "Show Exit Safe Exam Browser button, configured with this quit link" Then I should not see "Ask user to confirm quitting" Then I should not see "Enable reload in exam" Then I should not see "Show SEB task bar" Then I should not see "Show reload button" Then I should not see "Show time" Then I should not see "Show keyboard layout" Then I should not see "Show Wi-Fi control" Then I should not see "Enable audio controls" Then I should not see "Mute on startup" Then I should not see "Allow browser access to camera" Then I should not see "Allow browser access to microphone" Then I should not see "Enable spell checking" Then I should not see "Enable URL filtering" Then I should not see "Filter also embedded content" Then I should not see "Expressions allowed" Then I should not see "Regex allowed" Then I should not see "Expressions blocked" Then I should not see "Regex blocked" And I set the field "Enable quitting of SEB" to "No" Then I should not see "Quit password" Scenario: SEB settings if using Configure manually Given the following "quizaccess_seb > seb templates" exist: | name | | Template 1 | And I am on the "Quiz 1" "quiz activity editing" page logged in as admin And I expand all fieldsets And I set the field "Require the use of Safe Exam Browser" to "Yes – Configure manually" Then I should see "Show Safe Exam Browser download button" Then I should see "Enable quitting of SEB" Then I should see "Quit password" Then I should see "Show Exit Safe Exam Browser button, configured with this quit link" Then I should see "Ask user to confirm quitting" Then I should see "Enable reload in exam" Then I should see "Show SEB task bar" Then I should see "Show reload button" Then I should see "Show time" Then I should see "Show keyboard layout" Then I should see "Show Wi-Fi control" Then I should see "Enable audio controls" Then I should not see "Mute on startup" Then I should see "Allow browser access to camera" Then I should see "Allow browser access to microphone" Then I should see "Enable spell checking" Then I should see "Enable URL filtering" Then I should not see "Filter also embedded content" Then I should not see "Expressions allowed" Then I should not see "Regex allowed" Then I should not see "Expressions blocked" Then I should not see "Regex blocked" And I set the field "Enable quitting of SEB" to "No" Then I should not see "Quit password" And I set the field "Show SEB task bar" to "No" Then I should not see "Show reload button" Then I should not see "Show time" Then I should not see "Show keyboard layout" Then I should not see "Show Wi-Fi control" And I set the field "Enable audio controls" to "Yes" Then I should see "Mute on startup" And I set the field "Enable URL filtering" to "Yes" Then I should see "Filter also embedded content" Then I should see "Expressions allowed" Then I should see "Regex allowed" Then I should see "Expressions blocked" Then I should see "Regex blocked" Then I should not see "Upload Safe Exam Browser config file" Then I should not see "Allowed browser exam keys" Then I should not see "Safe Exam Browser config template" Then I should not see "Template 1" Scenario: Disable templates that are already in use and seeing their visibility in settings Given the following "quizaccess_seb > seb templates" exist: | name | enabled | | Template 1 | 1 | | Template 2 | 0 | # Set Quiz 1 template to Template 1 When I am on the "Quiz 1" "quiz activity editing" page logged in as admin And I expand all fieldsets And I set the field "Require the use of Safe Exam Browser" to "Yes – Use an existing template" Then I should see "Safe Exam Browser config template" And the "Safe Exam Browser config template" select box should contain "Template 1" And the "Safe Exam Browser config template" select box should not contain "Template 2" And I set the field "Safe Exam Browser config template" to "Template 1" And I press "Save and return to course" # Disable Template 1 And I navigate to "Plugins > Activity modules > Quiz > Safe Exam Browser templates" in site administration And I click on "Edit" "link" in the "Template 1" "table_row" And I set the field "Enabled" to "No" And I press "Save changes" # Check Quiz 1 is still using Template 1 When I am on the "Quiz 1" "quiz activity editing" page logged in as admin And I expand all fieldsets And the field "Require the use of Safe Exam Browser" matches value "Yes – Use an existing template" Then I should see "Template 1" And the "Safe Exam Browser config template" select box should contain "Template 1" And the "Safe Exam Browser config template" select box should not contain "Template 2" # Check Quiz 3 cannot use any templates as they're all disabled When I am on the "Quiz 2" "quiz activity editing" page logged in as admin And I expand all fieldsets And the "Require the use of Safe Exam Browser" select box should not contain "Yes – Use an existing template" PK9B\WA/accessrule/seb/tests/behat/attempt_quiz.featurenu[@javascript @mod_quiz @quizaccess @quizaccess_seb Feature: Quiz start with Safe Exam Browser access rule Background: Given the following "courses" exist: | fullname | shortname | | Course 1 | C1 | And the following "users" exist: | username | firstname | lastname | email | | teacher1 | Teacher | 1 | teacher1@example.com | | student1 | Student | 1 | student1@example.com | And the following "course enrolments" exist: | user | course | role | | teacher1 | C1 | editingteacher | | student1 | C1 | student | And the following "activities" exist: | activity | course | section | name | | quiz | C1 | 1 | Quiz 1 | And the following "question categories" exist: | contextlevel | reference | name | | Course | C1 | Test questions | And the following "questions" exist: | questioncategory | qtype | name | questiontext | | Test questions | truefalse | TF1 | First question | And quiz "Quiz 1" contains the following questions: | question | page | maxmark | | TF1 | 1 | | Scenario: Start a quiz as student with Safe Exam Browser quiz access rule When I am on the "Quiz 1" "quiz activity editing" page logged in as teacher1 And I expand all fieldsets And I set the following fields to these values: | seb_requiresafeexambrowser | 1 | And I press "Save and return to course" And I am on the "Quiz 1" "mod_quiz > View" page logged in as "student1" Then the "target" attribute of "//a[text()='Download Safe Exam Browser']" "xpath_element" should contain "_blank" PK9B\U)accessrule/seb/tests/hideif_rule_test.phpnu[. namespace quizaccess_seb; /** * PHPUnit tests for hideif_rule. * * @package quizaccess_seb * @author Dmitrii Metelkin * @copyright 2020 Catalyst IT * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class hideif_rule_test extends \advanced_testcase { /** * Test that can get rule data. */ public function test_can_get_what_set_in_constructor(): void { $rule = new hideif_rule('Element', 'Dependant', 'eq', 'Value'); $this->assertEquals('Element', $rule->get_element()); $this->assertEquals('Dependant', $rule->get_dependantname()); $this->assertEquals('eq', $rule->get_condition()); $this->assertEquals('Value', $rule->get_dependantvalue()); } } PK9B\=M)M)*accessrule/seb/tests/test_helper_trait.phpnu[. /** * A test helper trait. * * @package quizaccess_seb * @author Andrew Madden * @copyright 2019 Catalyst IT * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ use mod_quiz\local\access_rule_base; use mod_quiz\quiz_attempt; use quizaccess_seb\seb_access_manager; use quizaccess_seb\settings_provider; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->dirroot . "/mod/quiz/accessrule/seb/rule.php"); // Include plugin rule class. require_once($CFG->dirroot . "/mod/quiz/mod_form.php"); // Include plugin rule class. /** * A test helper trait. It has some common helper methods. * * @copyright 2020 Catalyst IT * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ trait quizaccess_seb_test_helper_trait { /** @var \stdClass $course Test course to contain quiz. */ protected $course; /** @var \stdClass $quiz A test quiz. */ protected $quiz; /** @var \stdClass $user A test logged-in user. */ protected $user; /** * Assign a capability to $USER * The function creates a student $USER if $USER->id is empty * * @param string $capability Capability name. * @param int $contextid Context ID. * @param int $roleid Role ID. * @return int The role id - mainly returned for creation, so calling function can reuse it. */ protected function assign_user_capability($capability, $contextid, $roleid = null) { global $USER; // Create a new student $USER if $USER doesn't exist. if (empty($USER->id)) { $user = $this->getDataGenerator()->create_user(); $this->setUser($user); } if (empty($roleid)) { $roleid = \create_role('Dummy role', 'dummyrole', 'dummy role description'); } \assign_capability($capability, CAP_ALLOW, $roleid, $contextid); \role_assign($roleid, $USER->id, $contextid); \accesslib_clear_all_caches_for_unit_testing(); return $roleid; } /** * Strip the seb_ prefix from each setting key. * * @param \stdClass $settings Object containing settings. * @return \stdClass The modified settings object. */ protected function strip_all_prefixes(\stdClass $settings): \stdClass { $newsettings = new \stdClass(); foreach ($settings as $name => $setting) { $newname = preg_replace("/^seb_/", "", $name); $newsettings->$newname = $setting; // Add new key. } return $newsettings; } /** * Creates a file in the user draft area. * * @param string $xml * @return int The user draftarea id */ protected function create_test_draftarea_file(string $xml): int { global $USER; $itemid = 0; $usercontext = \context_user::instance($USER->id); $filerecord = [ 'contextid' => \context_user::instance($USER->id)->id, 'component' => 'user', 'filearea' => 'draft', 'itemid' => $itemid, 'filepath' => '/', 'filename' => 'test.xml' ]; $fs = get_file_storage(); $fs->create_file_from_string($filerecord, $xml); $draftitemid = 0; file_prepare_draft_area($draftitemid, $usercontext->id, 'user', 'draft', 0); return $draftitemid; } /** * Create a file in a modules filearea. * * @param string $xml XML content of the file. * @param string $cmid Course module id. * @return int Item ID of file. */ protected function create_module_test_file(string $xml, string $cmid): int { $itemid = 0; $fs = get_file_storage(); $filerecord = [ 'contextid' => \context_module::instance($cmid)->id, 'component' => 'quizaccess_seb', 'filearea' => 'filemanager_sebconfigfile', 'itemid' => $itemid, 'filepath' => '/', 'filename' => 'test.xml' ]; $fs->create_file_from_string($filerecord, $xml); return $itemid; } /** * Create a test quiz for the specified course. * * @param \stdClass $course * @param int $requiresafeexambrowser How to use SEB for this quiz? * @return array */ protected function create_test_quiz($course, $requiresafeexambrowser = settings_provider::USE_SEB_NO) { $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz'); $quiz = $quizgenerator->create_instance([ 'course' => $course->id, 'questionsperpage' => 0, 'grade' => 100.0, 'sumgrades' => 2, 'seb_requiresafeexambrowser' => $requiresafeexambrowser, ]); $quiz->seb_showsebdownloadlink = 1; $quiz->coursemodule = $quiz->cmid; // Create a question bank. $qbank = self::getDataGenerator()->create_module('qbank', ['course' => $course->id]); $qbankcontext = context_module::instance($qbank->cmid); // Create a couple of questions. $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); $cat = $questiongenerator->create_question_category(['contextid' => $qbankcontext->id]); $saq = $questiongenerator->create_question('shortanswer', null, ['category' => $cat->id]); quiz_add_quiz_question($saq->id, $quiz); $numq = $questiongenerator->create_question('numerical', null, ['category' => $cat->id]); quiz_add_quiz_question($numq->id, $quiz); return $quiz; } /** * Answer questions for a quiz + user. * * @param \stdClass $quiz Quiz to attempt. * @param \stdClass $user A user to attempt the quiz. * @return array */ protected function attempt_quiz($quiz, $user) { $this->setUser($user); $starttime = time(); $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); // Start the attempt. $attempt = quiz_create_attempt($quizobj, 1, false, $starttime, false, $user->id); quiz_start_new_attempt($quizobj, $quba, $attempt, 1, $starttime); quiz_attempt_save_started($quizobj, $quba, $attempt); // Answer the questions. $attemptobj = quiz_attempt::create($attempt->id); $tosubmit = [ 1 => ['answer' => 'frog'], 2 => ['answer' => '3.14'], ]; $attemptobj->process_submitted_actions($starttime, false, $tosubmit); // Finish the attempt. $attemptobj = quiz_attempt::create($attempt->id); $attemptobj->process_submit($starttime, false); $attemptobj->process_grade_submission($starttime); $this->setUser(); return [$quizobj, $quba, $attemptobj]; } /** * Create test template. * * @param string|null $xml Template content. * @return \quizaccess_seb\template Just created template. */ public function create_template(?string $xml = null) { $data = []; if (!is_null($xml)) { $data['content'] = $xml; } return $this->getDataGenerator()->get_plugin_generator('quizaccess_seb')->create_template($data); } /** * Get access manager for testing. * * @return \quizaccess_seb\seb_access_manager */ protected function get_access_manager() { return new seb_access_manager(new mod_quiz\quiz_settings($this->quiz, get_coursemodule_from_id('quiz', $this->quiz->cmid), $this->course)); } /** * A helper method to make the rule form the currently created quiz and course. * * @return access_rule_base|null */ protected function make_rule() { return \quizaccess_seb::make( new mod_quiz\quiz_settings($this->quiz, get_coursemodule_from_id('quiz', $this->quiz->cmid), $this->course), 0, true ); } /** * A helper method to set up quiz view page. */ protected function set_up_quiz_view_page() { global $PAGE; $page = new \moodle_page(); $page->set_context(\context_module::instance($this->quiz->cmid)); $page->set_course($this->course); $page->set_pagelayout('standard'); $page->set_pagetype("mod-quiz-view"); $page->set_url('/mod/quiz/view.php?id=' . $this->quiz->cmid); $PAGE = $page; } /** * Get a test object containing mock test settings. * * @return \stdClass Settings. */ protected function get_test_settings(array $settings = []): \stdClass { return (object) array_merge([ 'quizid' => 1, 'cmid' => 1, 'requiresafeexambrowser' => '1', 'showsebtaskbar' => '1', 'showwificontrol' => '0', 'showreloadbutton' => '1', 'showtime' => '0', 'showkeyboardlayout' => '1', 'allowuserquitseb' => '1', 'quitpassword' => 'test', 'linkquitseb' => '', 'userconfirmquit' => '1', 'enableaudiocontrol' => '1', 'muteonstartup' => '0', 'allowcapturecamera' => '1', 'allowcapturemicrophone' => '1', 'allowspellchecking' => '0', 'allowreloadinexam' => '1', 'activateurlfiltering' => '1', 'filterembeddedcontent' => '0', 'expressionsallowed' => 'test.com', 'regexallowed' => '', 'expressionsblocked' => '', 'regexblocked' => '', 'showsebdownloadlink' => '1', ], $settings); } } PK9B\`3K޺accessrule/seb/template.phpnu[. /** * Page to manipulate templates. * * @package quizaccess_seb * @author Dmitrii Metelkin * @copyright 2020 Catalyst IT * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ require_once('../../../../config.php'); require_once($CFG->libdir . '/adminlib.php'); $action = optional_param('action', 'view', PARAM_ALPHANUMEXT); $PAGE->set_context(context_system::instance()); $manager = new \quizaccess_seb\template_controller(); $manager->execute($action); PK9B\)lpT T accessrule/seb/settings.phpnu[. /** * Global configuration settings for the quizaccess_seb plugin. * * @package quizaccess_seb * @author Andrew Madden * @author Dmitrii Metelkin * @copyright 2019 Catalyst IT * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die; global $ADMIN; if ($hassiteconfig) { $settings->add(new admin_setting_heading( 'quizaccess_seb/supportedversions', '', $OUTPUT->notification(get_string('setting:supportedversions', 'quizaccess_seb'), 'warning'))); $settings->add(new admin_setting_configcheckbox('quizaccess_seb/autoreconfigureseb', get_string('setting:autoreconfigureseb', 'quizaccess_seb'), get_string('setting:autoreconfigureseb_desc', 'quizaccess_seb'), '1')); $links = [ 'seb' => get_string('setting:showseblink', 'quizaccess_seb'), 'http' => get_string('setting:showhttplink', 'quizaccess_seb') ]; $settings->add(new admin_setting_configmulticheckbox('quizaccess_seb/showseblinks', get_string('setting:showseblinks', 'quizaccess_seb'), get_string('setting:showseblinks_desc', 'quizaccess_seb'), $links, $links)); $settings->add(new admin_setting_configtext('quizaccess_seb/downloadlink', get_string('setting:downloadlink', 'quizaccess_seb'), get_string('setting:downloadlink_desc', 'quizaccess_seb'), 'https://safeexambrowser.org/download_en.html', PARAM_URL)); $settings->add(new admin_setting_configcheckbox('quizaccess_seb/quizpasswordrequired', get_string('setting:quizpasswordrequired', 'quizaccess_seb'), get_string('setting:quizpasswordrequired_desc', 'quizaccess_seb'), '0')); $settings->add(new admin_setting_configcheckbox('quizaccess_seb/displayblocksbeforestart', get_string('setting:displayblocksbeforestart', 'quizaccess_seb'), get_string('setting:displayblocksbeforestart_desc', 'quizaccess_seb'), '0')); $settings->add(new admin_setting_configcheckbox('quizaccess_seb/displayblockswhenfinished', get_string('setting:displayblockswhenfinished', 'quizaccess_seb'), get_string('setting:displayblockswhenfinished_desc', 'quizaccess_seb'), '1')); } if (has_capability('quizaccess/seb:managetemplates', context_system::instance())) { $ADMIN->add('modsettingsquizcat', new admin_externalpage( 'quizaccess_seb/template', get_string('manage_templates', 'quizaccess_seb'), new moodle_url('/mod/quiz/accessrule/seb/template.php'), 'quizaccess/seb:managetemplates' ) ); } PK9B\fcttKaccessrule/delaybetweenattempts/lang/en/quizaccess_delaybetweenattempts.phpnu[. /** * Strings for the quizaccess_delaybetweenattempts plugin. * * @package quizaccess * @subpackage delaybetweenattempts * @copyright 2011 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); $string['pluginname'] = 'Delay between attempts quiz access rule'; $string['privacy:metadata'] = 'The Delay between attempts quiz access rule plugin does not store any personal data.'; $string['youcannotwait'] = 'This quiz closes before you will be allowed to start another attempt.'; $string['youmustwait'] = 'You must wait before you may re-attempt this quiz. You will be allowed to start another attempt after {$a}.'; PK9B\XUU+accessrule/delaybetweenattempts/version.phpnu[. /** * Version information for the quizaccess_delaybetweenattempts plugin. * * @package quizaccess * @subpackage delaybetweenattempts * @copyright 2011 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 = 'quizaccess_delaybetweenattempts'; PK9B\+BB<accessrule/delaybetweenattempts/classes/privacy/provider.phpnu[. /** * Privacy Subsystem implementation for quizaccess_delaybetweenattempts. * * @package quizaccess_delaybetweenattempts * @copyright 2018 Andrew Nicols * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace quizaccess_delaybetweenattempts\privacy; defined('MOODLE_INTERNAL') || die(); /** * Privacy Subsystem for quizaccess_delaybetweenattempts 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'; } } PK9B\Y (accessrule/delaybetweenattempts/rule.phpnu[. use mod_quiz\local\access_rule_base; use mod_quiz\quiz_settings; /** * A rule imposing the delay between attempts settings. * * @package quizaccess_delaybetweenattempts * @copyright 2009 Tim Hunt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class quizaccess_delaybetweenattempts extends access_rule_base { public static function make(quiz_settings $quizobj, $timenow, $canignoretimelimits) { if (empty($quizobj->get_quiz()->delay1) && empty($quizobj->get_quiz()->delay2)) { return null; } return new self($quizobj, $timenow); } public function prevent_new_attempt($numprevattempts, $lastattempt) { if ($this->quiz->attempts > 0 && $numprevattempts >= $this->quiz->attempts) { // No more attempts allowed anyway. return false; } if ($this->quiz->timeclose != 0 && $this->timenow > $this->quiz->timeclose) { // No more attempts allowed anyway. return false; } $nextstarttime = $this->compute_next_start_time($numprevattempts, $lastattempt); if ($this->timenow < $nextstarttime) { if ($this->quiz->timeclose == 0 || $nextstarttime <= $this->quiz->timeclose) { return get_string('youmustwait', 'quizaccess_delaybetweenattempts', userdate($nextstarttime)); } else { return get_string('youcannotwait', 'quizaccess_delaybetweenattempts'); } } return false; } /** * Compute the next time a student would be allowed to start an attempt, * according to this rule. * @param int $numprevattempts number of previous attempts. * @param stdClass $lastattempt information about the previous attempt. * @return number the time. */ protected function compute_next_start_time($numprevattempts, $lastattempt) { if ($numprevattempts == 0) { return 0; } $lastattemptfinish = $lastattempt->timefinish; if ($this->quiz->timelimit > 0) { $lastattemptfinish = min($lastattemptfinish, $lastattempt->timestart + $this->quiz->timelimit); } if ($numprevattempts == 1 && $this->quiz->delay1) { return $lastattemptfinish + $this->quiz->delay1; } else if ($numprevattempts > 1 && $this->quiz->delay2) { return $lastattemptfinish + $this->quiz->delay2; } return 0; } public function is_finished($numprevattempts, $lastattempt) { $nextstarttime = $this->compute_next_start_time($numprevattempts, $lastattempt); return $this->timenow <= $nextstarttime && $this->quiz->timeclose != 0 && $nextstarttime >= $this->quiz->timeclose; } } PK9B\6c773accessrule/delaybetweenattempts/tests/rule_test.phpnu[. namespace quizaccess_delaybetweenattempts; use mod_quiz\quiz_settings; use quizaccess_delaybetweenattempts; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->dirroot . '/mod/quiz/accessrule/delaybetweenattempts/rule.php'); /** * Unit tests for the quizaccess_delaybetweenattempts plugin. * * @package quizaccess_delaybetweenattempts * @category test * @copyright 2008 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class rule_test extends \basic_testcase { public function test_just_first_delay(): void { $quiz = new \stdClass(); $quiz->attempts = 3; $quiz->timelimit = 0; $quiz->delay1 = 1000; $quiz->delay2 = 0; $quiz->timeclose = 0; $cm = new \stdClass(); $cm->id = 0; $quizobj = new quiz_settings($quiz, $cm, null); $attempt = new \stdClass(); $attempt->timefinish = 10000; $rule = new quizaccess_delaybetweenattempts($quizobj, 10000); $this->assertEmpty($rule->description()); $this->assertFalse($rule->prevent_access()); $this->assertFalse($rule->is_finished(0, $attempt)); $this->assertFalse($rule->end_time($attempt)); $this->assertFalse($rule->time_left_display($attempt, 0)); $this->assertFalse($rule->prevent_new_attempt(0, $attempt)); $this->assertFalse($rule->prevent_new_attempt(3, $attempt)); $this->assertEquals($rule->prevent_new_attempt(1, $attempt), get_string('youmustwait', 'quizaccess_delaybetweenattempts', userdate(11000))); $this->assertFalse($rule->prevent_new_attempt(2, $attempt)); $attempt->timefinish = 9000; $this->assertFalse($rule->prevent_new_attempt(1, $attempt)); $this->assertFalse($rule->prevent_new_attempt(2, $attempt)); $attempt->timefinish = 9001; $this->assertEquals($rule->prevent_new_attempt(1, $attempt), get_string('youmustwait', 'quizaccess_delaybetweenattempts', userdate(10001))); $this->assertFalse($rule->prevent_new_attempt(2, $attempt)); } public function test_just_second_delay(): void { $quiz = new \stdClass(); $quiz->attempts = 5; $quiz->timelimit = 0; $quiz->delay1 = 0; $quiz->delay2 = 1000; $quiz->timeclose = 0; $cm = new \stdClass(); $cm->id = 0; $quizobj = new quiz_settings($quiz, $cm, null); $attempt = new \stdClass(); $attempt->timefinish = 10000; $rule = new quizaccess_delaybetweenattempts($quizobj, 10000); $this->assertEmpty($rule->description()); $this->assertFalse($rule->prevent_access()); $this->assertFalse($rule->is_finished(0, $attempt)); $this->assertFalse($rule->end_time($attempt)); $this->assertFalse($rule->time_left_display($attempt, 0)); $this->assertFalse($rule->prevent_new_attempt(0, $attempt)); $this->assertFalse($rule->prevent_new_attempt(5, $attempt)); $this->assertFalse($rule->prevent_new_attempt(1, $attempt)); $this->assertEquals($rule->prevent_new_attempt(2, $attempt), get_string('youmustwait', 'quizaccess_delaybetweenattempts', userdate(11000))); $this->assertEquals($rule->prevent_new_attempt(3, $attempt), get_string('youmustwait', 'quizaccess_delaybetweenattempts', userdate(11000))); $attempt->timefinish = 9000; $this->assertFalse($rule->prevent_new_attempt(1, $attempt)); $this->assertFalse($rule->prevent_new_attempt(2, $attempt)); $this->assertFalse($rule->prevent_new_attempt(3, $attempt)); $attempt->timefinish = 9001; $this->assertFalse($rule->prevent_new_attempt(1, $attempt)); $this->assertEquals($rule->prevent_new_attempt(2, $attempt), get_string('youmustwait', 'quizaccess_delaybetweenattempts', userdate(10001))); $this->assertEquals($rule->prevent_new_attempt(4, $attempt), get_string('youmustwait', 'quizaccess_delaybetweenattempts', userdate(10001))); } public function test_just_both_delays(): void { $quiz = new \stdClass(); $quiz->attempts = 5; $quiz->timelimit = 0; $quiz->delay1 = 2000; $quiz->delay2 = 1000; $quiz->timeclose = 0; $cm = new \stdClass(); $cm->id = 0; $quizobj = new quiz_settings($quiz, $cm, null); $attempt = new \stdClass(); $attempt->timefinish = 10000; $rule = new quizaccess_delaybetweenattempts($quizobj, 10000); $this->assertEmpty($rule->description()); $this->assertFalse($rule->prevent_access()); $this->assertFalse($rule->is_finished(0, $attempt)); $this->assertFalse($rule->end_time($attempt)); $this->assertFalse($rule->time_left_display($attempt, 0)); $this->assertFalse($rule->prevent_new_attempt(0, $attempt)); $this->assertFalse($rule->prevent_new_attempt(5, $attempt)); $this->assertEquals($rule->prevent_new_attempt(1, $attempt), get_string('youmustwait', 'quizaccess_delaybetweenattempts', userdate(12000))); $this->assertEquals($rule->prevent_new_attempt(2, $attempt), get_string('youmustwait', 'quizaccess_delaybetweenattempts', userdate(11000))); $this->assertEquals($rule->prevent_new_attempt(3, $attempt), get_string('youmustwait', 'quizaccess_delaybetweenattempts', userdate(11000))); $attempt->timefinish = 8000; $this->assertFalse($rule->prevent_new_attempt(1, $attempt)); $this->assertFalse($rule->prevent_new_attempt(2, $attempt)); $this->assertFalse($rule->prevent_new_attempt(3, $attempt)); $attempt->timefinish = 8001; $this->assertEquals($rule->prevent_new_attempt(1, $attempt), get_string('youmustwait', 'quizaccess_delaybetweenattempts', userdate(10001))); $this->assertFalse($rule->prevent_new_attempt(2, $attempt)); $this->assertFalse($rule->prevent_new_attempt(4, $attempt)); $attempt->timefinish = 9000; $this->assertEquals($rule->prevent_new_attempt(1, $attempt), get_string('youmustwait', 'quizaccess_delaybetweenattempts', userdate(11000))); $this->assertFalse($rule->prevent_new_attempt(2, $attempt)); $this->assertFalse($rule->prevent_new_attempt(3, $attempt)); $attempt->timefinish = 9001; $this->assertEquals($rule->prevent_new_attempt(1, $attempt), get_string('youmustwait', 'quizaccess_delaybetweenattempts', userdate(11001))); $this->assertEquals($rule->prevent_new_attempt(2, $attempt), get_string('youmustwait', 'quizaccess_delaybetweenattempts', userdate(10001))); $this->assertEquals($rule->prevent_new_attempt(4, $attempt), get_string('youmustwait', 'quizaccess_delaybetweenattempts', userdate(10001))); } public function test_with_close_date(): void { $quiz = new \stdClass(); $quiz->attempts = 5; $quiz->timelimit = 0; $quiz->delay1 = 2000; $quiz->delay2 = 1000; $quiz->timeclose = 15000; $cm = new \stdClass(); $cm->id = 0; $quizobj = new quiz_settings($quiz, $cm, null); $attempt = new \stdClass(); $attempt->timefinish = 13000; $rule = new quizaccess_delaybetweenattempts($quizobj, 10000); $this->assertEmpty($rule->description()); $this->assertFalse($rule->prevent_access()); $this->assertFalse($rule->is_finished(0, $attempt)); $this->assertFalse($rule->end_time($attempt)); $this->assertFalse($rule->time_left_display($attempt, 0)); $attempt->timefinish = 13000; $this->assertEquals($rule->prevent_new_attempt(1, $attempt), get_string('youmustwait', 'quizaccess_delaybetweenattempts', userdate(15000))); $attempt->timefinish = 13001; $this->assertEquals($rule->prevent_new_attempt(1, $attempt), get_string('youcannotwait', 'quizaccess_delaybetweenattempts')); $attempt->timefinish = 14000; $this->assertEquals($rule->prevent_new_attempt(2, $attempt), get_string('youmustwait', 'quizaccess_delaybetweenattempts', userdate(15000))); $attempt->timefinish = 14001; $this->assertEquals($rule->prevent_new_attempt(2, $attempt), get_string('youcannotwait', 'quizaccess_delaybetweenattempts')); $rule = new quizaccess_delaybetweenattempts($quizobj, 15000); $attempt->timefinish = 13000; $this->assertFalse($rule->prevent_new_attempt(1, $attempt)); $attempt->timefinish = 13001; $this->assertEquals($rule->prevent_new_attempt(1, $attempt), get_string('youcannotwait', 'quizaccess_delaybetweenattempts')); $attempt->timefinish = 14000; $this->assertFalse($rule->prevent_new_attempt(2, $attempt)); $attempt->timefinish = 14001; $this->assertEquals($rule->prevent_new_attempt(2, $attempt), get_string('youcannotwait', 'quizaccess_delaybetweenattempts')); $rule = new quizaccess_delaybetweenattempts($quizobj, 15001); $attempt->timefinish = 13000; $this->assertFalse($rule->prevent_new_attempt(1, $attempt)); $attempt->timefinish = 13001; $this->assertFalse($rule->prevent_new_attempt(1, $attempt)); $attempt->timefinish = 14000; $this->assertFalse($rule->prevent_new_attempt(2, $attempt)); $attempt->timefinish = 14001; $this->assertFalse($rule->prevent_new_attempt(2, $attempt)); } public function test_time_limit_and_overdue(): void { $quiz = new \stdClass(); $quiz->attempts = 5; $quiz->timelimit = 100; $quiz->delay1 = 2000; $quiz->delay2 = 1000; $quiz->timeclose = 0; $cm = new \stdClass(); $cm->id = 0; $quizobj = new quiz_settings($quiz, $cm, null); $attempt = new \stdClass(); $attempt->timestart = 9900; $attempt->timefinish = 10100; $rule = new quizaccess_delaybetweenattempts($quizobj, 10000); $this->assertEmpty($rule->description()); $this->assertFalse($rule->prevent_access()); $this->assertFalse($rule->is_finished(0, $attempt)); $this->assertFalse($rule->end_time($attempt)); $this->assertFalse($rule->time_left_display($attempt, 0)); $this->assertFalse($rule->prevent_new_attempt(0, $attempt)); $this->assertFalse($rule->prevent_new_attempt(5, $attempt)); $this->assertEquals($rule->prevent_new_attempt(1, $attempt), get_string('youmustwait', 'quizaccess_delaybetweenattempts', userdate(12000))); $this->assertEquals($rule->prevent_new_attempt(2, $attempt), get_string('youmustwait', 'quizaccess_delaybetweenattempts', userdate(11000))); $this->assertEquals($rule->prevent_new_attempt(3, $attempt), get_string('youmustwait', 'quizaccess_delaybetweenattempts', userdate(11000))); $attempt->timestart = 7950; $attempt->timefinish = 8000; $this->assertFalse($rule->prevent_new_attempt(1, $attempt)); $this->assertFalse($rule->prevent_new_attempt(2, $attempt)); $this->assertFalse($rule->prevent_new_attempt(3, $attempt)); $attempt->timestart = 7950; $attempt->timefinish = 8001; $this->assertEquals($rule->prevent_new_attempt(1, $attempt), get_string('youmustwait', 'quizaccess_delaybetweenattempts', userdate(10001))); $this->assertFalse($rule->prevent_new_attempt(2, $attempt)); $this->assertFalse($rule->prevent_new_attempt(4, $attempt)); $attempt->timestart = 8950; $attempt->timefinish = 9000; $this->assertEquals($rule->prevent_new_attempt(1, $attempt), get_string('youmustwait', 'quizaccess_delaybetweenattempts', userdate(11000))); $this->assertFalse($rule->prevent_new_attempt(2, $attempt)); $this->assertFalse($rule->prevent_new_attempt(3, $attempt)); $attempt->timestart = 8950; $attempt->timefinish = 9001; $this->assertEquals($rule->prevent_new_attempt(1, $attempt), get_string('youmustwait', 'quizaccess_delaybetweenattempts', userdate(11001))); $this->assertEquals($rule->prevent_new_attempt(2, $attempt), get_string('youmustwait', 'quizaccess_delaybetweenattempts', userdate(10001))); $this->assertEquals($rule->prevent_new_attempt(4, $attempt), get_string('youmustwait', 'quizaccess_delaybetweenattempts', userdate(10001))); $attempt->timestart = 8900; $attempt->timefinish = 9100; $this->assertEquals($rule->prevent_new_attempt(1, $attempt), get_string('youmustwait', 'quizaccess_delaybetweenattempts', userdate(11000))); $this->assertFalse($rule->prevent_new_attempt(2, $attempt)); $this->assertFalse($rule->prevent_new_attempt(3, $attempt)); $attempt->timestart = 8901; $attempt->timefinish = 9100; $this->assertEquals($rule->prevent_new_attempt(1, $attempt), get_string('youmustwait', 'quizaccess_delaybetweenattempts', userdate(11001))); $this->assertEquals($rule->prevent_new_attempt(2, $attempt), get_string('youmustwait', 'quizaccess_delaybetweenattempts', userdate(10001))); $this->assertEquals($rule->prevent_new_attempt(4, $attempt), get_string('youmustwait', 'quizaccess_delaybetweenattempts', userdate(10001))); } } PK9B\̋{{Aaccessrule/offlineattempts/lang/en/quizaccess_offlineattempts.phpnu[. /** * Strings for the quizaccess_offlineattempts plugin. * * @package quizaccess_offlineattempts * @copyright 2016 Juan Leyva * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); $string['allowofflineattempts'] = 'Allow quiz to be attempted offline using the mobile app'; $string['allowofflineattempts_help'] = 'If enabled, a mobile app user can download the quiz and attempt it offline. Note: It is not possible for a quiz to be attempted offline if it has a time limit, or requires a network address, or uses any question behaviour other than deferred feedback (with or without CBM), or uses sequential navigation.'; $string['confirmdatasaved'] = 'I confirm that I do not have any unsaved work on a mobile device.'; $string['mobileapp'] = 'Mobile app'; $string['offlineattemptserror'] = 'It is not possible for a quiz to be attempted offline if it has a time limit, or requires a network address, or uses any question behaviour other than deferred feedback (with or without CBM), or uses sequential navigation.'; $string['offlinedatamessage'] = 'You have worked on this attempt using a mobile device. Data was last saved to this site {$a} ago.'; $string['pleaseconfirm'] = 'Please check and confirm that you do not have any unsaved work.'; $string['pluginname'] = 'Offline attempts access rule'; $string['privacy:metadata'] = 'The Offline attempts quiz access rule plugin does not store any personal data.'; PK9B\TT,,&accessrule/offlineattempts/version.phpnu[. /** * Version information for the quizaccess_offlineattempts plugin. * * @package quizaccess_offlineattempts * @copyright 2016 Juan Leyva * @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 = 'quizaccess_offlineattempts'; PK9B\Dw..7accessrule/offlineattempts/classes/privacy/provider.phpnu[. /** * Privacy Subsystem implementation for quizaccess_offlineattempts. * * @package quizaccess_offlineattempts * @copyright 2018 Andrew Nicols * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace quizaccess_offlineattempts\privacy; defined('MOODLE_INTERNAL') || die(); /** * Privacy Subsystem for quizaccess_offlineattempts 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'; } } PK9B\*  #accessrule/offlineattempts/rule.phpnu[. use mod_quiz\form\preflight_check_form; use mod_quiz\local\access_rule_base; use mod_quiz\quiz_settings; /** * A rule implementing the offlineattempts check. * * @package quizaccess_offlineattempts * @copyright 2016 Juan Leyva * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @since Moodle 3.2 */ class quizaccess_offlineattempts extends access_rule_base { public static function make(quiz_settings $quizobj, $timenow, $canignoretimelimits) { global $CFG; // If mobile services are off, the user won't be able to use any external app. if (empty($CFG->enablemobilewebservice) or empty($quizobj->get_quiz()->allowofflineattempts)) { return null; } return new self($quizobj, $timenow); } public function is_preflight_check_required($attemptid) { global $SESSION, $DB; // First, check if the user did something offline. if (!empty($attemptid)) { $timemodifiedoffline = $DB->get_field('quiz_attempts', 'timemodifiedoffline', ['id' => $attemptid]); if (empty($timemodifiedoffline)) { return false; } return empty($SESSION->offlineattemptscheckedquizzes[$this->quiz->id]); } else { // Starting a new attempt, we don't have to check anything here. return false; } } public function add_preflight_check_form_fields(preflight_check_form $quizform, MoodleQuickForm $mform, $attemptid) { global $DB; $timemodifiedoffline = $DB->get_field('quiz_attempts', 'timemodifiedoffline', ['id' => $attemptid]); $lasttime = format_time(time() - $timemodifiedoffline); $mform->addElement('header', 'offlineattemptsheader', get_string('mobileapp', 'quizaccess_offlineattempts')); $mform->addElement('static', 'offlinedatamessage', '', get_string('offlinedatamessage', 'quizaccess_offlineattempts', $lasttime)); $mform->addElement('advcheckbox', 'confirmdatasaved', null, get_string('confirmdatasaved', 'quizaccess_offlineattempts')); } public function validate_preflight_check($data, $files, $errors, $attemptid) { // The user confirmed that he doesn't have unsaved work. if (!empty($data['confirmdatasaved'])) { return $errors; } $errors['confirmdatasaved'] = get_string('pleaseconfirm', 'quizaccess_offlineattempts'); return $errors; } public function notify_preflight_check_passed($attemptid) { global $SESSION; $SESSION->offlineattemptscheckedquizzes[$this->quiz->id] = true; } public function current_attempt_finished() { global $SESSION; // Clear the flag in the session that says that the user has already agreed to the notice. if (!empty($SESSION->offlineattemptscheckedquizzes[$this->quiz->id])) { unset($SESSION->offlineattemptscheckedquizzes[$this->quiz->id]); } } public static function add_settings_form_fields( mod_quiz_mod_form $quizform, MoodleQuickForm $mform) { global $CFG; // Allow to enable the access rule only if the Mobile services are enabled. if ($CFG->enablemobilewebservice) { $mform->addElement('selectyesno', 'allowofflineattempts', get_string('allowofflineattempts', 'quizaccess_offlineattempts')); $mform->addHelpButton('allowofflineattempts', 'allowofflineattempts', 'quizaccess_offlineattempts'); $mform->setDefault('allowofflineattempts', 0); $mform->setAdvanced('allowofflineattempts'); $mform->disabledIf('allowofflineattempts', 'timelimit[number]', 'neq', 0); $mform->disabledIf('allowofflineattempts', 'subnet', 'neq', ''); $mform->disabledIf('allowofflineattempts', 'navmethod', 'eq', 'sequential'); } } public static function validate_settings_form_fields(array $errors, array $data, $files, mod_quiz_mod_form $quizform) { global $CFG; if ($CFG->enablemobilewebservice) { // Do not allow offline attempts if: // - The quiz uses a timer. // - The quiz is restricted by subnet. // - The question behaviour is not deferred feedback or deferred feedback with CBM. // - The quiz uses the sequential navigation. if (!empty($data['allowofflineattempts']) && (!empty($data['timelimit']) || !empty($data['subnet']) || $data['navmethod'] === 'sequential' || ($data['preferredbehaviour'] != 'deferredfeedback' && $data['preferredbehaviour'] != 'deferredcbm'))) { $errors['allowofflineattempts'] = get_string('offlineattemptserror', 'quizaccess_offlineattempts'); } } return $errors; } } PK9B\G8//.accessrule/offlineattempts/tests/rule_test.phpnu[. namespace quizaccess_offlineattempts; use mod_quiz\quiz_settings; use quizaccess_offlineattempts; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->dirroot . '/mod/quiz/accessrule/offlineattempts/rule.php'); /** * Unit tests for the quizaccess_offlineattempts plugin. * * @package quizaccess_offlineattempts * @copyright 2016 Juan Leyva * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class rule_test extends \basic_testcase { public function test_offlineattempts_access_rule(): void { $quiz = new \stdClass(); $quiz->allowofflineattempts = 1; $cm = new \stdClass(); $cm->id = 0; $quizobj = new quiz_settings($quiz, $cm, null); $rule = new quizaccess_offlineattempts($quizobj, 0); $attempt = new \stdClass(); $this->assertFalse($rule->prevent_access()); $this->assertFalse($rule->prevent_new_attempt(0, $attempt)); $this->assertFalse($rule->is_finished(0, $attempt)); $this->assertFalse($rule->end_time($attempt)); $this->assertFalse($rule->time_left_display($attempt, 0)); } } PK9B\53accessrule/password/lang/en/quizaccess_password.phpnu[. /** * Strings for the quizaccess_password plugin. * * @package quizaccess * @subpackage password * @copyright 2011 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); $string['passworderror'] = 'The password entered was incorrect'; $string['pluginname'] = 'Password quiz access rule'; $string['privacy:metadata'] = 'The Password quiz access rule plugin does not store any personal data.'; $string['quizpassword'] = 'Quiz password'; $string['requirepasswordmessage'] = 'To attempt this quiz you need to know the quiz password'; PK9B\Y11accessrule/password/version.phpnu[. /** * Version information for the quizaccess_password plugin. * * @package quizaccess * @subpackage password * @copyright 2011 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 = 'quizaccess_password'; PK9B\K0accessrule/password/classes/privacy/provider.phpnu[. /** * Privacy Subsystem implementation for quizaccess_password. * * @package quizaccess_password * @copyright 2018 Andrew Nicols * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace quizaccess_password\privacy; defined('MOODLE_INTERNAL') || die(); /** * Privacy Subsystem for quizaccess_password 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'; } } PK9B\. o o accessrule/password/rule.phpnu[. use mod_quiz\form\preflight_check_form; use mod_quiz\local\access_rule_base; use mod_quiz\quiz_settings; /** * A rule implementing the password check. * * @package quizaccess_password * @copyright 2009 Tim Hunt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class quizaccess_password extends access_rule_base { public static function make(quiz_settings $quizobj, $timenow, $canignoretimelimits) { if (empty($quizobj->get_quiz()->password)) { return null; } return new self($quizobj, $timenow); } public function description() { return get_string('requirepasswordmessage', 'quizaccess_password'); } public function is_preflight_check_required($attemptid) { global $SESSION; return empty($SESSION->passwordcheckedquizzes[$this->quiz->id]); } public function add_preflight_check_form_fields(preflight_check_form $quizform, MoodleQuickForm $mform, $attemptid) { $mform->addElement('header', 'passwordheader', get_string('password')); $mform->addElement('static', 'passwordmessage', '', get_string('requirepasswordmessage', 'quizaccess_password')); // Don't use the 'proper' field name of 'password' since that get's // Firefox's password auto-complete over-excited. $mform->addElement('passwordunmask', 'quizpassword', get_string('quizpassword', 'quizaccess_password'), ['autofocus' => 'true']); } public function validate_preflight_check($data, $files, $errors, $attemptid) { $enteredpassword = $data['quizpassword']; if (strcmp($this->quiz->password, $enteredpassword) === 0) { return $errors; // Password is OK. } else if (isset($this->quiz->extrapasswords)) { // Group overrides may have additional passwords. foreach ($this->quiz->extrapasswords as $password) { if (strcmp($password, $enteredpassword) === 0) { return $errors; // Password is OK. } } } $errors['quizpassword'] = get_string('passworderror', 'quizaccess_password'); return $errors; } public function notify_preflight_check_passed($attemptid) { global $SESSION; $SESSION->passwordcheckedquizzes[$this->quiz->id] = true; } public function current_attempt_finished() { global $SESSION; // Clear the flag in the session that says that the user has already // entered the password for this quiz. if (!empty($SESSION->passwordcheckedquizzes[$this->quiz->id])) { unset($SESSION->passwordcheckedquizzes[$this->quiz->id]); } } } PK9B\Gː'accessrule/password/tests/rule_test.phpnu[. namespace quizaccess_password; use mod_quiz\quiz_settings; use quizaccess_password; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->dirroot . '/mod/quiz/accessrule/password/rule.php'); /** * Unit tests for the quizaccess_password plugin. * * @package quizaccess_password * @category test * @copyright 2008 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class rule_test extends \basic_testcase { public function test_password_access_rule(): void { $quiz = new \stdClass(); $quiz->password = 'frog'; $cm = new \stdClass(); $cm->id = 0; $quizobj = new quiz_settings($quiz, $cm, null); $rule = new quizaccess_password($quizobj, 0); $attempt = new \stdClass(); $this->assertFalse($rule->prevent_access()); $this->assertEquals($rule->description(), get_string('requirepasswordmessage', 'quizaccess_password')); $this->assertFalse($rule->prevent_new_attempt(0, $attempt)); $this->assertFalse($rule->is_finished(0, $attempt)); $this->assertFalse($rule->end_time($attempt)); $this->assertFalse($rule->time_left_display($attempt, 0)); } } PK9B\Q_kkaccessrule/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 quiz access rule plugins. Overview of this plugin type at http://docs.moodle.org/dev/Quiz_access_rules === 4.2 === * Note that class mod_quiz_preflight_check_form has been renamed to mod_quiz\form\preflight_check_form. * The base class quiz_access_rule_base has been moved to mod_quiz\local\access_rule_base. Please: 1. update your class declaration to ... extends access_rule_base { 2. Add use mod_quiz\local\access_rule_base; 3. Remove require_once($CFG->dirroot . '/mod/quiz/accessrule/accessrulebase.php'); === 2.8, 2.7.1, 2.6.4 and 2.5.7 === * New static method delete_settings for access rules, which is called when a quiz is deleted. === 2.4 and 2.3.4 === * Replaced time_left() with new time_left_display() and end_time() functions. === 2.3 === * This plugin type now supports cron in the standard way. If required, Create a lib.php file containing function quizaccess_mypluginname_cron() {}; === 2.2 === * This plugin type was new in Moodle 2.2! PK9B\=accessrule/openclosedate/lang/en/quizaccess_openclosedate.phpnu[. /** * Strings for the quizaccess_openclosedate plugin. * * @package quizaccess * @subpackage openclosedate * @copyright 2011 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ $string['notavailable'] = 'This quiz is currently not available.'; $string['pluginname'] = 'Open and close date access rule'; $string['privacy:metadata'] = 'The Open and close date quiz access rule plugin does not store any personal data.'; PK9B\|4C@@$accessrule/openclosedate/version.phpnu[. /** * Version information for the quizaccess_openclosedate plugin. * * @package quizaccess * @subpackage openclosedate * @copyright 2011 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 = 'quizaccess_openclosedate'; PK9B\ &&5accessrule/openclosedate/classes/privacy/provider.phpnu[. /** * Privacy Subsystem implementation for quizaccess_openclosedate. * * @package quizaccess_openclosedate * @copyright 2018 Andrew Nicols * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace quizaccess_openclosedate\privacy; defined('MOODLE_INTERNAL') || die(); /** * Privacy Subsystem for quizaccess_openclosedate 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'; } } PK9B\`, !accessrule/openclosedate/rule.phpnu[. use mod_quiz\local\access_rule_base; use mod_quiz\quiz_settings; /** * A rule enforcing open and close dates. * * @package quizaccess_openclosedate * @copyright 2009 Tim Hunt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class quizaccess_openclosedate extends access_rule_base { public static function make(quiz_settings $quizobj, $timenow, $canignoretimelimits) { // This rule is always used, even if the quiz has no open or close date. return new self($quizobj, $timenow); } public function prevent_access() { $message = get_string('notavailable', 'quizaccess_openclosedate'); if ($this->timenow < $this->quiz->timeopen) { return $message; } if (!$this->quiz->timeclose) { return false; } if ($this->timenow <= $this->quiz->timeclose) { return false; } if ($this->quiz->overduehandling != 'graceperiod') { return $message; } if ($this->timenow <= $this->quiz->timeclose + $this->quiz->graceperiod) { return false; } return $message; } public function is_finished($numprevattempts, $lastattempt) { return $this->quiz->timeclose && $this->timenow > $this->quiz->timeclose; } public function end_time($attempt) { if ($this->quiz->timeclose) { return $this->quiz->timeclose; } return false; } public function time_left_display($attempt, $timenow) { // If this is a teacher preview after the close date, do not show // the time. if ($attempt->preview && $timenow > $this->quiz->timeclose) { return false; } // Otherwise, return to the time left until the close date, providing that is // less than QUIZ_SHOW_TIME_BEFORE_DEADLINE. $endtime = $this->end_time($attempt); if ($endtime !== false && $timenow > $endtime - QUIZ_SHOW_TIME_BEFORE_DEADLINE) { return $endtime - $timenow; } return false; } } PK9B\ӶQ,accessrule/openclosedate/tests/rule_test.phpnu[. namespace quizaccess_openclosedate; use mod_quiz\quiz_settings; use quizaccess_openclosedate; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->dirroot . '/mod/quiz/accessrule/openclosedate/rule.php'); /** * Unit tests for the quizaccess_openclosedate plugin. * * @package quizaccess_openclosedate * @category test * @copyright 2008 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class rule_test extends \basic_testcase { public function test_no_dates(): void { $quiz = new \stdClass(); $quiz->timeopen = 0; $quiz->timeclose = 0; $quiz->overduehandling = 'autosubmit'; $cm = new \stdClass(); $cm->id = 0; $quizobj = new quiz_settings($quiz, $cm, null); $attempt = new \stdClass(); $attempt->preview = 0; $rule = new quizaccess_openclosedate($quizobj, 10000); $this->assertFalse($rule->prevent_access()); $this->assertFalse($rule->prevent_new_attempt(0, $attempt)); $this->assertFalse($rule->is_finished(0, $attempt)); $this->assertFalse($rule->end_time($attempt)); $this->assertFalse($rule->time_left_display($attempt, 10000)); $this->assertFalse($rule->time_left_display($attempt, 0)); $rule = new quizaccess_openclosedate($quizobj, 0); $this->assertFalse($rule->prevent_access()); $this->assertFalse($rule->prevent_new_attempt(0, $attempt)); $this->assertFalse($rule->is_finished(0, $attempt)); $this->assertFalse($rule->end_time($attempt)); $this->assertFalse($rule->time_left_display($attempt, 0)); } public function test_start_date(): void { $quiz = new \stdClass(); $quiz->timeopen = 10000; $quiz->timeclose = 0; $quiz->overduehandling = 'autosubmit'; $cm = new \stdClass(); $cm->id = 0; $quizobj = new quiz_settings($quiz, $cm, null); $attempt = new \stdClass(); $attempt->preview = 0; $rule = new quizaccess_openclosedate($quizobj, 9999); $this->assertEquals($rule->prevent_access(), get_string('notavailable', 'quizaccess_openclosedate')); $this->assertFalse($rule->prevent_new_attempt(0, $attempt)); $this->assertFalse($rule->is_finished(0, $attempt)); $this->assertFalse($rule->end_time($attempt)); $this->assertFalse($rule->time_left_display($attempt, 0)); $rule = new quizaccess_openclosedate($quizobj, 10000); $this->assertFalse($rule->prevent_access()); $this->assertFalse($rule->prevent_new_attempt(0, $attempt)); $this->assertFalse($rule->is_finished(0, $attempt)); $this->assertFalse($rule->end_time($attempt)); $this->assertFalse($rule->time_left_display($attempt, 0)); } public function test_close_date(): void { $quiz = new \stdClass(); $quiz->timeopen = 0; $quiz->timeclose = 20000; $quiz->overduehandling = 'autosubmit'; $cm = new \stdClass(); $cm->id = 0; $quizobj = new quiz_settings($quiz, $cm, null); $attempt = new \stdClass(); $attempt->preview = 0; $rule = new quizaccess_openclosedate($quizobj, 20000); $this->assertFalse($rule->prevent_access()); $this->assertFalse($rule->prevent_new_attempt(0, $attempt)); $this->assertFalse($rule->is_finished(0, $attempt)); $this->assertEquals($rule->end_time($attempt), 20000); $this->assertFalse($rule->time_left_display($attempt, 20000 - QUIZ_SHOW_TIME_BEFORE_DEADLINE)); $this->assertEquals($rule->time_left_display($attempt, 19900), 100); $this->assertEquals($rule->time_left_display($attempt, 20000), 0); $this->assertEquals($rule->time_left_display($attempt, 20100), -100); $rule = new quizaccess_openclosedate($quizobj, 20001); $this->assertEquals($rule->prevent_access(), get_string('notavailable', 'quizaccess_openclosedate')); $this->assertFalse($rule->prevent_new_attempt(0, $attempt)); $this->assertTrue($rule->is_finished(0, $attempt)); $this->assertEquals($rule->end_time($attempt), 20000); $this->assertFalse($rule->time_left_display($attempt, 20000 - QUIZ_SHOW_TIME_BEFORE_DEADLINE)); $this->assertEquals($rule->time_left_display($attempt, 19900), 100); $this->assertEquals($rule->time_left_display($attempt, 20000), 0); $this->assertEquals($rule->time_left_display($attempt, 20100), -100); } public function test_both_dates(): void { $quiz = new \stdClass(); $quiz->timeopen = 10000; $quiz->timeclose = 20000; $quiz->overduehandling = 'autosubmit'; $cm = new \stdClass(); $cm->id = 0; $quizobj = new quiz_settings($quiz, $cm, null); $attempt = new \stdClass(); $attempt->preview = 0; $rule = new quizaccess_openclosedate($quizobj, 9999); $this->assertEquals($rule->prevent_access(), get_string('notavailable', 'quizaccess_openclosedate')); $this->assertFalse($rule->prevent_new_attempt(0, $attempt)); $this->assertFalse($rule->is_finished(0, $attempt)); $rule = new quizaccess_openclosedate($quizobj, 10000); $this->assertFalse($rule->prevent_access()); $this->assertFalse($rule->prevent_new_attempt(0, $attempt)); $this->assertFalse($rule->is_finished(0, $attempt)); $rule = new quizaccess_openclosedate($quizobj, 20000); $this->assertFalse($rule->prevent_access()); $this->assertFalse($rule->prevent_new_attempt(0, $attempt)); $this->assertFalse($rule->is_finished(0, $attempt)); $rule = new quizaccess_openclosedate($quizobj, 20001); $this->assertEquals($rule->prevent_access(), get_string('notavailable', 'quizaccess_openclosedate')); $this->assertFalse($rule->prevent_new_attempt(0, $attempt)); $this->assertTrue($rule->is_finished(0, $attempt)); $this->assertEquals($rule->end_time($attempt), 20000); $this->assertFalse($rule->time_left_display($attempt, 20000 - QUIZ_SHOW_TIME_BEFORE_DEADLINE)); $this->assertEquals($rule->time_left_display($attempt, 19900), 100); $this->assertEquals($rule->time_left_display($attempt, 20000), 0); $this->assertEquals($rule->time_left_display($attempt, 20100), -100); } public function test_close_date_with_overdue(): void { $quiz = new \stdClass(); $quiz->timeopen = 0; $quiz->timeclose = 20000; $quiz->overduehandling = 'graceperiod'; $quiz->graceperiod = 1000; $cm = new \stdClass(); $cm->id = 0; $quizobj = new quiz_settings($quiz, $cm, null); $attempt = new \stdClass(); $attempt->preview = 0; $rule = new quizaccess_openclosedate($quizobj, 20000); $this->assertFalse($rule->prevent_access()); $rule = new quizaccess_openclosedate($quizobj, 20001); $this->assertFalse($rule->prevent_access()); $rule = new quizaccess_openclosedate($quizobj, 21000); $this->assertFalse($rule->prevent_access()); $rule = new quizaccess_openclosedate($quizobj, 21001); $this->assertEquals($rule->prevent_access(), get_string('notavailable', 'quizaccess_openclosedate')); } } PK9B\S39accessrule/numattempts/lang/en/quizaccess_numattempts.phpnu[. /** * Strings for the quizaccess_numattempts plugin. * * @package quizaccess * @subpackage numattempts * @copyright 2011 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); $string['attemptsallowedn'] = 'Attempts allowed: {$a}'; $string['pluginname'] = 'Number of attempts quiz access rule'; $string['privacy:metadata'] = 'The Number of attempts quiz access rule plugin does not store any personal data.'; PK9B\::"accessrule/numattempts/version.phpnu[. /** * Version information for the quizaccess_numattempts plugin. * * @package quizaccess * @subpackage numattempts * @copyright 2011 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 = 'quizaccess_numattempts'; PK9B\j)3accessrule/numattempts/classes/privacy/provider.phpnu[. /** * Privacy Subsystem implementation for quizaccess_numattempts. * * @package quizaccess_numattempts * @copyright 2018 Andrew Nicols * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace quizaccess_numattempts\privacy; defined('MOODLE_INTERNAL') || die(); /** * Privacy Subsystem for quizaccess_numattempts 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'; } } PK9B\igaccessrule/numattempts/rule.phpnu[. use mod_quiz\local\access_rule_base; use mod_quiz\quiz_settings; /** * A rule controlling the number of attempts allowed. * * @package quizaccess_numattempts * @copyright 2009 Tim Hunt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class quizaccess_numattempts extends access_rule_base { public static function make(quiz_settings $quizobj, $timenow, $canignoretimelimits) { if ($quizobj->get_num_attempts_allowed() == 0) { return null; } return new self($quizobj, $timenow); } public function description() { return get_string('attemptsallowedn', 'quizaccess_numattempts', $this->quiz->attempts); } public function prevent_new_attempt($numprevattempts, $lastattempt) { if ($numprevattempts >= $this->quiz->attempts) { return get_string('nomoreattempts', 'quiz'); } return false; } public function is_finished($numprevattempts, $lastattempt) { return $numprevattempts >= $this->quiz->attempts; } } PK9B\8ْ *accessrule/numattempts/tests/rule_test.phpnu[. namespace quizaccess_numattempts; use mod_quiz\quiz_settings; use quizaccess_numattempts; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->dirroot . '/mod/quiz/accessrule/numattempts/rule.php'); /** * Unit tests for the quizaccess_numattempts plugin. * * @package quizaccess_numattempts * @category test * @copyright 2008 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class rule_test extends \basic_testcase { public function test_num_attempts_access_rule(): void { $quiz = new \stdClass(); $quiz->attempts = 3; $cm = new \stdClass(); $cm->id = 0; $quizobj = new quiz_settings($quiz, $cm, null); $rule = new quizaccess_numattempts($quizobj, 0); $attempt = new \stdClass(); $this->assertEquals($rule->description(), get_string('attemptsallowedn', 'quizaccess_numattempts', 3)); $this->assertFalse($rule->prevent_new_attempt(0, $attempt)); $this->assertFalse($rule->prevent_new_attempt(2, $attempt)); $this->assertEquals($rule->prevent_new_attempt(3, $attempt), get_string('nomoreattempts', 'quiz')); $this->assertEquals($rule->prevent_new_attempt(666, $attempt), get_string('nomoreattempts', 'quiz')); $this->assertFalse($rule->is_finished(0, $attempt)); $this->assertFalse($rule->is_finished(2, $attempt)); $this->assertTrue($rule->is_finished(3, $attempt)); $this->assertTrue($rule->is_finished(666, $attempt)); $this->assertFalse($rule->prevent_access()); $this->assertFalse($rule->end_time($attempt)); $this->assertFalse($rule->time_left_display($attempt, 0)); } } PK9B\u=5accessrule/ipaddress/lang/en/quizaccess_ipaddress.phpnu[. /** * Strings for the quizaccess_ipaddress plugin. * * @package quizaccess * @subpackage ipaddress * @copyright 2011 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); $string['pluginname'] = 'IP address quiz access rule'; $string['privacy:metadata'] = 'The IP address quiz access rule plugin does not store any personal data.'; $string['subnetwrong'] = 'This quiz is only accessible from certain locations, and this computer is not on the allowed list.'; PK9B\ w44 accessrule/ipaddress/version.phpnu[. /** * Version information for the quizaccess_ipaddress plugin. * * @package quizaccess * @subpackage ipaddress * @copyright 2011 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 = 'quizaccess_ipaddress'; PK9B\f`1accessrule/ipaddress/classes/privacy/provider.phpnu[. /** * Privacy Subsystem implementation for quizaccess_ipaddress. * * @package quizaccess_ipaddress * @copyright 2018 Andrew Nicols * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace quizaccess_ipaddress\privacy; defined('MOODLE_INTERNAL') || die(); /** * Privacy Subsystem for quizaccess_ipaddress 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'; } } PK9B\`accessrule/ipaddress/rule.phpnu[. use mod_quiz\local\access_rule_base; use mod_quiz\quiz_settings; /** * A rule implementing the ipaddress check against the ->subnet setting. * * @package quizaccess_ipaddress * @copyright 2009 Tim Hunt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class quizaccess_ipaddress extends access_rule_base { public static function make(quiz_settings $quizobj, $timenow, $canignoretimelimits) { if (empty($quizobj->get_quiz()->subnet)) { return null; } return new self($quizobj, $timenow); } public function prevent_access() { if (address_in_subnet(getremoteaddr(), $this->quiz->subnet)) { return false; } else { return get_string('subnetwrong', 'quizaccess_ipaddress'); } } } PK9B\Z$L L (accessrule/ipaddress/tests/rule_test.phpnu[. namespace quizaccess_ipaddress; use mod_quiz\quiz_settings; use quizaccess_ipaddress; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->dirroot . '/mod/quiz/accessrule/ipaddress/rule.php'); /** * Unit tests for the quizaccess_ipaddress plugin. * * @package quizaccess_ipaddress * @category test * @copyright 2008 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class rule_test extends \basic_testcase { public function test_ipaddress_access_rule(): void { $quiz = new \stdClass(); $attempt = new \stdClass(); $cm = new \stdClass(); $cm->id = 0; // Test the allowed case by getting the user's IP address. However, this // does not always work, for example using the mac install package on my laptop. $quiz->subnet = getremoteaddr(null); if (!empty($quiz->subnet)) { $quizobj = new quiz_settings($quiz, $cm, null); $rule = new quizaccess_ipaddress($quizobj, 0); $this->assertFalse($rule->prevent_access()); $this->assertFalse($rule->description()); $this->assertFalse($rule->prevent_new_attempt(0, $attempt)); $this->assertFalse($rule->is_finished(0, $attempt)); $this->assertFalse($rule->end_time($attempt)); $this->assertFalse($rule->time_left_display($attempt, 0)); } $quiz->subnet = '0.0.0.0'; $quizobj = new quiz_settings($quiz, $cm, null); $rule = new quizaccess_ipaddress($quizobj, 0); $this->assertNotEmpty($rule->prevent_access()); $this->assertEmpty($rule->description()); $this->assertFalse($rule->prevent_new_attempt(0, $attempt)); $this->assertFalse($rule->is_finished(0, $attempt)); $this->assertFalse($rule->end_time($attempt)); $this->assertFalse($rule->time_left_display($attempt, 0)); } } PK9B\A;mmaccessrule/accessrulebase.phpnu[. /** * File only retained to prevent fatal errors in code that tries to require/include this. * * @todo MDL-76612 delete this file as part of Moodle 4.6 development. * @deprecated This file is no longer required in Moodle 4.2+. */ defined('MOODLE_INTERNAL') || die(); debugging('This file is no longer required in Moodle 4.2+. Please do not include/require it.', DEBUG_DEVELOPER); require_once($CFG->dirroot . '/mod/quiz/locallib.php'); PK9B\;accessrule/securewindow/lang/en/quizaccess_securewindow.phpnu[. /** * Strings for the quizaccess_securewindow plugin. * * @package quizaccess * @subpackage securewindow * @copyright 2011 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); $string['pluginname'] = 'JavaScript security quiz access rule'; $string['popupwithjavascriptsupport'] = 'Full screen pop-up with some JavaScript security'; $string['privacy:metadata'] = 'The JavaScript security quiz access rule plugin does not store any personal data.'; PK9B\먐==#accessrule/securewindow/version.phpnu[. /** * Version information for the quizaccess_securewindow plugin. * * @package quizaccess * @subpackage securewindow * @copyright 2011 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 = 'quizaccess_securewindow'; PK9B\hr 8""4accessrule/securewindow/classes/privacy/provider.phpnu[. /** * Privacy Subsystem implementation for quizaccess_securewindow. * * @package quizaccess_securewindow * @copyright 2018 Andrew Nicols * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace quizaccess_securewindow\privacy; defined('MOODLE_INTERNAL') || die(); /** * Privacy Subsystem for quizaccess_securewindow 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'; } } PK9B\½< accessrule/securewindow/rule.phpnu[. use mod_quiz\local\access_rule_base; use mod_quiz\quiz_settings; /** * A rule for ensuring that the quiz is opened in a popup, with some JavaScript * to prevent copying and pasting, etc. * * @package quizaccess_securewindow * @copyright 2009 Tim Hunt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class quizaccess_securewindow extends access_rule_base { /** @var array options that should be used for opening the secure popup. */ protected static $popupoptions = [ 'left' => 0, 'top' => 0, 'fullscreen' => true, 'scrollbars' => true, 'resizeable' => false, 'directories' => false, 'toolbar' => false, 'titlebar' => false, 'location' => false, 'status' => false, 'menubar' => false, ]; public static function make(quiz_settings $quizobj, $timenow, $canignoretimelimits) { if ($quizobj->get_quiz()->browsersecurity !== 'securewindow') { return null; } return new self($quizobj, $timenow); } public function attempt_must_be_in_popup() { return !$this->quizobj->is_preview_user(); } public function get_popup_options() { return self::$popupoptions; } public function setup_attempt_page($page) { $page->set_popup_notification_allowed(false); // Prevent message notifications. $page->set_title($this->quizobj->get_course()->shortname . ': ' . $page->title); $page->set_pagelayout('secure'); if ($this->quizobj->is_preview_user()) { return; } $page->add_body_class('quiz-secure-window'); $page->requires->js_init_call('M.mod_quiz.secure_window.init', null, false, quiz_get_js_module()); } /** * @return array key => lang string any choices to add to the quiz Browser * security settings menu. */ public static function get_browser_security_choices() { return ['securewindow' => get_string('popupwithjavascriptsupport', 'quizaccess_securewindow')]; } } PK9B\`x+accessrule/securewindow/tests/rule_test.phpnu[. namespace quizaccess_securewindow; use mod_quiz\quiz_settings; use quizaccess_securewindow; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->dirroot . '/mod/quiz/accessrule/securewindow/rule.php'); /** * Unit tests for the quizaccess_securewindow plugin. * * @package quizaccess_securewindow * @copyright 2008 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * * @covers \mod_quiz\local\access_rule_base * @covers \quizaccess_securewindow */ final class rule_test extends \basic_testcase { // Nothing very testable in this class, just test that it obeys the general access rule contact. public function test_securewindow_access_rule(): void { $quiz = new \stdClass(); $quiz->browsersecurity = 'securewindow'; $cm = new \stdClass(); $cm->id = 0; $quizobj = new quiz_settings($quiz, $cm, null); $rule = new quizaccess_securewindow($quizobj, 0); $attempt = new \stdClass(); $this->assertFalse($rule->prevent_access()); $this->assertEmpty($rule->description()); $this->assertFalse($rule->prevent_new_attempt(0, $attempt)); $this->assertFalse($rule->is_finished(0, $attempt)); $this->assertFalse($rule->end_time($attempt)); $this->assertFalse($rule->time_left_display($attempt, 0)); } } PK9B\g5accessrule/timelimit/lang/en/quizaccess_timelimit.phpnu[. /** * Strings for the quizaccess_timelimit plugin. * * @package quizaccess * @subpackage timelimit * @copyright 2011 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); $string['confirmstartheader'] = 'Time limit'; $string['confirmstart'] = 'Your attempt will have a time limit of {$a}. When you start, the timer will begin to count down and cannot be paused. You must finish your attempt before it expires. Are you sure you wish to start now?'; $string['pluginname'] = 'Time limit quiz access rule'; $string['privacy:metadata'] = 'The Time limit quiz access rule plugin does not store any personal data.'; $string['quiztimelimit'] = 'Time limit: {$a}'; PK9B\کUK?? accessrule/timelimit/version.phpnu[. /** * Version information for the quizaccess_delaybetweenattempts plugin. * * @package quizaccess * @subpackage timelimit * @copyright 2011 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 = 'quizaccess_timelimit'; PK9B\a_1accessrule/timelimit/classes/privacy/provider.phpnu[. /** * Privacy Subsystem implementation for quizaccess_timelimit. * * @package quizaccess_timelimit * @copyright 2018 Andrew Nicols * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace quizaccess_timelimit\privacy; defined('MOODLE_INTERNAL') || die(); /** * Privacy Subsystem for quizaccess_timelimit 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'; } } PK9B\A[_" accessrule/timelimit/rule.phpnu[. use mod_quiz\form\preflight_check_form; use mod_quiz\local\access_rule_base; use mod_quiz\quiz_settings; /** * A rule representing the time limit. * * It does not actually restrict access, but we use this * class to encapsulate some of the relevant code. * * @package quizaccess_timelimit * @copyright 2009 Tim Hunt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class quizaccess_timelimit extends access_rule_base { public static function make(quiz_settings $quizobj, $timenow, $canignoretimelimits) { if (empty($quizobj->get_quiz()->timelimit) || $canignoretimelimits) { return null; } return new self($quizobj, $timenow); } public function description() { return get_string('quiztimelimit', 'quizaccess_timelimit', format_time($this->quiz->timelimit)); } public function end_time($attempt) { $timedue = $attempt->timestart + $this->quiz->timelimit; if ($this->quiz->timeclose) { $timedue = min($timedue, $this->quiz->timeclose); } return $timedue; } public function time_left_display($attempt, $timenow) { // If this is a teacher preview after the time limit expires, don't show the time_left $endtime = $this->end_time($attempt); if ($attempt->preview && $timenow > $endtime) { return false; } return $endtime - $timenow; } public function is_preflight_check_required($attemptid) { // Warning only required if the attempt is not already started. return $attemptid === null; } public function add_preflight_check_form_fields(preflight_check_form $quizform, MoodleQuickForm $mform, $attemptid) { $mform->addElement('header', 'honestycheckheader', get_string('confirmstartheader', 'quizaccess_timelimit')); $mform->addElement('static', 'honestycheckmessage', '', get_string('confirmstart', 'quizaccess_timelimit', format_time($this->quiz->timelimit))); } } PK9B\(accessrule/timelimit/tests/rule_test.phpnu[. namespace quizaccess_timelimit; use mod_quiz\quiz_settings; use quizaccess_timelimit; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->dirroot . '/mod/quiz/accessrule/timelimit/rule.php'); /** * Unit tests for the quizaccess_timelimit plugin. * * @package quizaccess_timelimit * @copyright 2008 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class rule_test extends \basic_testcase { public function test_time_limit_access_rule(): void { $quiz = new \stdClass(); $quiz->timeclose = 0; $quiz->timelimit = 3600; $cm = new \stdClass(); $cm->id = 0; $quizobj = new quiz_settings($quiz, $cm, null); $rule = new quizaccess_timelimit($quizobj, 10000); $attempt = new \stdClass(); $this->assertEquals($rule->description(), get_string('quiztimelimit', 'quizaccess_timelimit', format_time(3600))); $attempt->timestart = 10000; $attempt->preview = 0; $this->assertEquals(13600, $rule->end_time($attempt)); $this->assertEquals(3600, $rule->time_left_display($attempt, 10000)); $this->assertEquals(1600, $rule->time_left_display($attempt, 12000)); $this->assertEquals(-400, $rule->time_left_display($attempt, 14000)); $this->assertFalse($rule->prevent_access()); $this->assertFalse($rule->prevent_new_attempt(0, $attempt)); $this->assertFalse($rule->is_finished(0, $attempt)); } /** * Data provider for test_time_limit_access_rule_with_time_close. * * @return array of ($timetoclose, $timelimit, $displaylimit, $actuallimit) */ public static function time_limit_access_rule_with_time_close_provider(): array { return [ 'Close time is earlier than time limit' => [1800, 3600, 3600, 1800], 'Close time is on time limit' => [3600, 3600, 3600, 3600], 'Close time is later than time limit' => [3600, 1800, 1800, 1800] ]; } /** * Test the time_left_display method of the quizaccess_timelimit class. * * @param int $timetoclose The number of seconds that is left to the quiz' closing time * @param int $timelimit Time limit of the quiz * @param int $displaylimit The limit that is displayed on the quiz page * @param int $actuallimit The actual limit that is being applied * @dataProvider time_limit_access_rule_with_time_close_provider */ public function test_time_limit_access_rule_with_time_close($timetoclose, $timelimit, $displaylimit, $actuallimit): void { $timenow = 10000; $quiz = new \stdClass(); $quiz->timeclose = $timenow + $timetoclose; $quiz->timelimit = $timelimit; $cm = new \stdClass(); $cm->id = 0; $quizobj = new quiz_settings($quiz, $cm, null); $rule = new quizaccess_timelimit($quizobj, $timenow); $attempt = new \stdClass(); $this->assertEquals($rule->description(), get_string('quiztimelimit', 'quizaccess_timelimit', format_time($displaylimit))); $attempt->timestart = $timenow; $attempt->preview = 0; $this->assertEquals($timenow + $actuallimit, $rule->end_time($attempt)); $this->assertEquals($actuallimit, $rule->time_left_display($attempt, $timenow)); $this->assertEquals($actuallimit - 1000, $rule->time_left_display($attempt, $timenow + 1000)); $this->assertFalse($rule->prevent_access()); $this->assertFalse($rule->prevent_new_attempt(0, $attempt)); $this->assertFalse($rule->is_finished(0, $attempt)); } } PK9B\+Weeedit.phpnu[. /** * Page to edit quizzes * * This page generally has two columns: * The right column lists all available questions in a chosen category and * allows them to be edited or more to be added. This column is only there if * the quiz does not already have student attempts * The left column lists all questions that have been added to the current quiz. * The lecturer can add questions from the right hand list to the quiz or remove them * * The script also processes a number of actions: * Actions affecting a quiz: * up and down Changes the order of questions and page breaks * addquestion Adds a single question to the quiz * add Adds several selected questions to the quiz * addrandom Adds a certain number of random questions to the quiz * repaginate Re-paginates the quiz * delete Removes a question from the quiz * savechanges Saves the order and grades for questions in the quiz * * @package mod_quiz * @copyright 1999 onwards Martin Dougiamas and others {@link http://moodle.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ use mod_quiz\output\edit_nav_actions; use mod_quiz\quiz_settings; require_once(__DIR__ . '/../../config.php'); require_once($CFG->dirroot . '/mod/quiz/locallib.php'); require_once($CFG->dirroot . '/question/editlib.php'); $mdlscrollto = optional_param('mdlscrollto', '', PARAM_INT); list($thispageurl, $contexts, $cmid, $cm, $quiz, $pagevars) = question_edit_setup('editq', '/mod/quiz/edit.php'); $PAGE->set_url($thispageurl); $PAGE->set_secondary_active_tab("mod_quiz_edit"); // You need mod/quiz:manage in addition to question capabilities to access this page. require_capability('mod/quiz:manage', $contexts->lowest()); // Get the course object and related bits. $course = get_course($quiz->course); $quizobj = new quiz_settings($quiz, $cm, $course); $structure = $quizobj->get_structure(); $gradecalculator = $quizobj->get_grade_calculator(); $defaultcategoryobj = question_get_default_category($contexts->lowest()->id, true); $defaultcategory = $defaultcategoryobj->id . ',' . $defaultcategoryobj->contextid; $quizhasattempts = quiz_has_attempts($quiz->id); // Process commands ============================================================. // Get the list of question ids had their check-boxes ticked. $selectedslots = []; $params = (array) data_submitted(); foreach ($params as $key => $value) { if (preg_match('!^s([0-9]+)$!', $key, $matches)) { $selectedslots[] = $matches[1]; } } $afteractionurl = new moodle_url($thispageurl); if (optional_param('repaginate', false, PARAM_BOOL) && confirm_sesskey()) { // Re-paginate the quiz. $structure->check_can_be_edited(); $questionsperpage = optional_param('questionsperpage', $quiz->questionsperpage, PARAM_INT); quiz_repaginate_questions($quiz->id, $questionsperpage ); quiz_delete_previews($quiz); redirect($afteractionurl); } if ($mdlscrollto) { $afteractionurl->param('mdlscrollto', $mdlscrollto); } if (($addquestion = optional_param('addquestion', 0, PARAM_INT)) && confirm_sesskey()) { // Add a single question to the current quiz. $structure->check_can_be_edited(); quiz_require_question_use($addquestion); $addonpage = optional_param('addonpage', 0, PARAM_INT); quiz_add_quiz_question($addquestion, $quiz, $addonpage); quiz_delete_previews($quiz); $gradecalculator->recompute_quiz_sumgrades(); $thispageurl->param('lastchanged', $addquestion); redirect($afteractionurl); } if (optional_param('add', false, PARAM_BOOL) && confirm_sesskey()) { $structure->check_can_be_edited(); $addonpage = optional_param('addonpage', 0, PARAM_INT); // Add selected questions to the current quiz. $rawdata = (array) data_submitted(); foreach ($rawdata as $key => $value) { // Parse input for question ids. if (preg_match('!^q([0-9]+)$!', $key, $matches)) { $key = $matches[1]; quiz_require_question_use($key); quiz_add_quiz_question($key, $quiz, $addonpage); } } quiz_delete_previews($quiz); $gradecalculator->recompute_quiz_sumgrades(); redirect($afteractionurl); } if ($addsectionatpage = optional_param('addsectionatpage', false, PARAM_INT)) { // Add a section to the quiz. $structure->check_can_be_edited(); $structure->add_section_heading($addsectionatpage); quiz_delete_previews($quiz); redirect($afteractionurl); } if ((optional_param('addrandom', false, PARAM_BOOL)) && confirm_sesskey()) { // Add random questions to the quiz. $structure->check_can_be_edited(); $recurse = optional_param('recurse', 0, PARAM_BOOL); $addonpage = optional_param('addonpage', 0, PARAM_INT); $categoryid = required_param('categoryid', PARAM_INT); $randomcount = required_param('randomcount', PARAM_INT); quiz_add_random_questions($quiz, $addonpage, $categoryid, $randomcount, $recurse); quiz_delete_previews($quiz); $gradecalculator->recompute_quiz_sumgrades(); redirect($afteractionurl); } if (optional_param('savechanges', false, PARAM_BOOL) && confirm_sesskey()) { // If rescaling is required save the new maximum. $maxgrade = unformat_float(optional_param('maxgrade', '', PARAM_RAW_TRIMMED), true); if (is_float($maxgrade) && $maxgrade >= 0) { $gradecalculator->update_quiz_maximum_grade($maxgrade); } redirect($afteractionurl); } // Log this visit. $event = \mod_quiz\event\edit_page_viewed::create([ 'courseid' => $course->id, 'context' => $contexts->lowest(), 'other' => [ 'quizid' => $quiz->id ] ]); $event->trigger(); // End of process commands =====================================================. $PAGE->set_pagelayout('incourse'); $PAGE->set_pagetype('mod-quiz-edit'); $output = $PAGE->get_renderer('mod_quiz', 'edit'); $PAGE->set_title(get_string('editingquizx', 'quiz', format_string($quiz->name))); $PAGE->set_heading($course->fullname); $PAGE->activityheader->disable(); $node = $PAGE->settingsnav->find('mod_quiz_edit', navigation_node::TYPE_SETTING); if ($node) { $node->make_active(); } // Add random question - result message. if ($message = optional_param('message', '', PARAM_TEXT)) { core\notification::add($message, core\notification::SUCCESS); } $tertiarynav = new edit_nav_actions($cmid, edit_nav_actions::SUMMARY); // Do output. echo $output->header(); echo $output->render($tertiarynav); // Initialise the JavaScript. $quizeditconfig = new stdClass(); $quizeditconfig->url = $thispageurl->out(true, ['qbanktool' => '0']); $quizeditconfig->dialoglisteners = []; $numberoflisteners = $DB->get_field_sql(" SELECT COALESCE(MAX(page), 1) FROM {quiz_slots} WHERE quizid = ?", [$quiz->id]); for ($pageiter = 1; $pageiter <= $numberoflisteners; $pageiter++) { $quizeditconfig->dialoglisteners[] = 'addrandomdialoglaunch_' . $pageiter; } $PAGE->requires->data_for_js('quiz_edit_config', $quizeditconfig); $PAGE->requires->js_call_amd('core_question/question_engine'); // Questions wrapper start. echo html_writer::start_tag('div', ['class' => 'mod-quiz-edit-content']); echo $output->edit_page($quizobj, $structure, $contexts, $thispageurl, $pagevars); // Questions wrapper end. echo html_writer::end_tag('div'); echo $output->footer(); PK9B\yui/src/autosave/build.jsonnu[{ "name": "moodle-mod_quiz-autosave", "builds": { "moodle-mod_quiz-autosave": { "jsfiles": [ "autosave.js" ] } } } PK9B\zE#yui/src/autosave/meta/autosave.jsonnu[{ "moodle-mod_quiz-autosave": { "requires": [ "base", "node", "event", "event-valuechange", "node-event-delegate", "io-form", "datatype-date-format" ] } } PK9B\DP88yui/src/autosave/js/autosave.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 . /** * Auto-save functionality for during quiz attempts. * * @module moodle-mod_quiz-autosave */ /** * Auto-save functionality for during quiz attempts. * * @class M.mod_quiz.autosave */ M.mod_quiz = M.mod_quiz || {}; M.mod_quiz.autosave = { /** * The amount of time (in milliseconds) to wait between TinyMCE detections. * * @property TINYMCE_DETECTION_DELAY * @type Number * @default 500 * @private */ TINYMCE_DETECTION_DELAY: 500, /** * The number of times to try redetecting TinyMCE. * * @property TINYMCE_DETECTION_REPEATS * @type Number * @default 20 * @private */ TINYMCE_DETECTION_REPEATS: 20, /** * The delay (in milliseconds) between checking hidden input fields. * * @property WATCH_HIDDEN_DELAY * @type Number * @default 1000 * @private */ WATCH_HIDDEN_DELAY: 1000, /** * The number of failures to ignore before notifying the user. * * @property FAILURES_BEFORE_NOTIFY * @type Number * @default 1 * @private */ FAILURES_BEFORE_NOTIFY: 1, /** * The value to use when resetting the successful save counter. * * @property FIRST_SUCCESSFUL_SAVE * @static * @type Number * @default -1 * @private */ FIRST_SUCCESSFUL_SAVE: -1, /** * The selectors used throughout this class. * * @property SELECTORS * @private * @type Object * @static */ SELECTORS: { QUIZ_FORM: '#responseform', VALUE_CHANGE_ELEMENTS: 'input, textarea, [contenteditable="true"]', CHANGE_ELEMENTS: 'input, select', HIDDEN_INPUTS: 'input[type=hidden]', CONNECTION_ERROR: '#connection-error', CONNECTION_OK: '#connection-ok' }, /** * The script which handles the autosaves. * * @property AUTOSAVE_HANDLER * @type String * @default M.cfg.wwwroot + '/mod/quiz/autosave.ajax.php' * @private */ AUTOSAVE_HANDLER: M.cfg.wwwroot + '/mod/quiz/autosave.ajax.php', /** * The delay (in milliseconds) between a change being made, and it being auto-saved. * * @property delay * @type Number * @default 120000 * @private */ delay: 120000, /** * A Node reference to the form we are monitoring. * * @property form * @type Node * @default null */ form: null, /** * Whether the form has been modified since the last save started. * * @property dirty * @type boolean * @default false */ dirty: false, /** * Timer object for the delay between form modifaction and the save starting. * * @property delay_timer * @type Object * @default null * @private */ delay_timer: null, /** * Y.io transaction for the save ajax request. * * @property save_transaction * @type object * @default null */ save_transaction: null, /** * Failed saves count. * * @property savefailures * @type Number * @default 0 * @private */ savefailures: 0, /** * Properly bound key change handler. * * @property editor_change_handler * @type EventHandle * @default null * @private */ editor_change_handler: null, /** * Record of the value of all the hidden fields, last time they were checked. * * @property hidden_field_values * @type Object * @default {} */ hidden_field_values: {}, /** * Initialise the autosave code. * * @method init * @param {Number} delay the delay, in seconds, between a change being detected, and * a save happening. */ init: function(delay) { this.form = Y.one(this.SELECTORS.QUIZ_FORM); if (!this.form) { Y.log('No response form found. Why did you try to set up autosave?', 'debug', 'moodle-mod_quiz-autosave'); return; } this.delay = delay * 1000; this.form.delegate('valuechange', this.value_changed, this.SELECTORS.VALUE_CHANGE_ELEMENTS, this); this.form.delegate('change', this.value_changed, this.SELECTORS.CHANGE_ELEMENTS, this); this.form.on('submit', this.stop_autosaving, this); require(['core_form/events'], function(FormEvent) { window.addEventListener(FormEvent.eventTypes.uploadChanged, this.value_changed.bind(this)); }.bind(this)); this.init_tinymce(this.TINYMCE_DETECTION_REPEATS); this.save_hidden_field_values(); this.watch_hidden_fields(); }, save_hidden_field_values: function() { this.form.all(this.SELECTORS.HIDDEN_INPUTS).each(function(hidden) { var name = hidden.get('name'); if (!name) { return; } this.hidden_field_values[name] = hidden.get('value'); }, this); }, watch_hidden_fields: function() { this.detect_hidden_field_changes(); Y.later(this.WATCH_HIDDEN_DELAY, this, this.watch_hidden_fields); }, detect_hidden_field_changes: function() { this.form.all(this.SELECTORS.HIDDEN_INPUTS).each(function(hidden) { var name = hidden.get('name'), value = hidden.get('value'); if (!name) { return; } if (!(name in this.hidden_field_values) || value !== this.hidden_field_values[name]) { this.hidden_field_values[name] = value; this.value_changed({target: hidden}); } }, this); }, /** * Initialise watching of TinyMCE specifically. * * Because TinyMCE might load slowly, and after us, we need to keep * trying, until we detect TinyMCE is there, or enough time has passed. * This is based on the TINYMCE_DETECTION_DELAY and * TINYMCE_DETECTION_REPEATS properties. * * * @method init_tinymce * @param {Number} repeatcount The number of attempts made so far. */ init_tinymce: function(repeatcount) { if (typeof window.tinyMCE === 'undefined') { if (repeatcount > 0) { Y.later(this.TINYMCE_DETECTION_DELAY, this, this.init_tinymce, [repeatcount - 1]); } else { Y.log('Gave up looking for TinyMCE.', 'debug', 'moodle-mod_quiz-autosave'); } return; } Y.log('Found TinyMCE.', 'debug', 'moodle-mod_quiz-autosave'); this.editor_change_handler = Y.bind(this.editor_changed, this); if (window.tinyMCE.onAddEditor) { window.tinyMCE.onAddEditor.add(Y.bind(this.init_tinymce_editor, this)); } else if (window.tinyMCE.on) { var startSaveTimer = this.start_save_timer_if_necessary.bind(this); window.tinyMCE.on('AddEditor', function(event) { event.editor.on('Change Undo Redo keydown', startSaveTimer); }); // One or more editors might already have been added, so we have to attach // the event handlers to these as well. window.tinyMCE.get().forEach(function(editor) { editor.on('Change Undo Redo keydown', startSaveTimer); }); } }, /** * Initialise watching of a specific TinyMCE editor. * * @method init_tinymce_editor * @param {EventFacade} e * @param {Object} editor The TinyMCE editor object */ init_tinymce_editor: function(e, editor) { Y.log('Found TinyMCE editor ' + editor.id + '.', 'debug', 'moodle-mod_quiz-autosave'); editor.onChange.add(this.editor_change_handler); editor.onRedo.add(this.editor_change_handler); editor.onUndo.add(this.editor_change_handler); editor.onKeyDown.add(this.editor_change_handler); }, value_changed: function(e) { var name = e.target.getAttribute('name'); if (name === 'thispage' || name === 'scrollpos' || (name && name.match(/_:flagged$/))) { return; // Not interesting. } // Fallback to the ID when the name is not present (in the case of content editable). name = name || '#' + e.target.getAttribute('id'); Y.log('Detected a value change in element ' + name + '.', 'debug', 'moodle-mod_quiz-autosave'); this.start_save_timer_if_necessary(); }, editor_changed: function(editor) { Y.log('Detected a value change in editor ' + editor.id + '.', 'debug', 'moodle-mod_quiz-autosave'); this.start_save_timer_if_necessary(); }, start_save_timer_if_necessary: function() { this.dirty = true; if (this.delay_timer || this.save_transaction) { // Already counting down or daving. return; } this.start_save_timer(); }, start_save_timer: function() { this.cancel_delay(); this.delay_timer = Y.later(this.delay, this, this.save_changes); }, cancel_delay: function() { if (this.delay_timer && this.delay_timer !== true) { this.delay_timer.cancel(); } this.delay_timer = null; }, save_changes: function() { this.cancel_delay(); this.dirty = false; if (this.is_time_nearly_over()) { Y.log('No more saving, time is nearly over.', 'debug', 'moodle-mod_quiz-autosave'); this.stop_autosaving(); return; } Y.log('Doing a save.', 'debug', 'moodle-mod_quiz-autosave'); if (typeof window.tinyMCE !== 'undefined') { window.tinyMCE.triggerSave(); } // YUI io.form incorrectly (in my opinion) sends the value of all submit // buttons in the ajax request. We don't want any submit buttons. // Therefore, temporarily change the type. // (Yes, this is a nasty hack. One day this will be re-written as AMD, hopefully). var allsubmitbuttons = this.form.all('input[type=submit], button[type=submit]'); allsubmitbuttons.setAttribute('type', 'button'); this.save_transaction = Y.io(this.AUTOSAVE_HANDLER, { method: 'POST', form: {id: this.form}, on: { success: this.save_done, failure: this.save_failed }, context: this }); // Change the button types back. allsubmitbuttons.setAttribute('type', 'submit'); }, save_done: function(transactionid, response) { var autosavedata = JSON.parse(response.responseText); if (autosavedata.status !== 'OK') { // Because IIS is useless, Moodle can't send proper HTTP response // codes, so we have to detect failures manually. this.save_failed(transactionid, response); return; } if (typeof autosavedata.timeleft !== 'undefined') { Y.log('Updating timer: ' + autosavedata.timeleft + ' seconds remain.', 'debug', 'moodle-mod_quiz-timer'); M.mod_quiz.timer.updateEndTime(autosavedata.timeleft); } this.update_saved_time_display(); Y.log('Save completed.', 'debug', 'moodle-mod_quiz-autosave'); this.save_transaction = null; if (this.dirty) { Y.log('Dirty after save.', 'debug', 'moodle-mod_quiz-autosave'); this.start_save_timer(); } if (this.savefailures > 0) { Y.one(this.SELECTORS.CONNECTION_ERROR).hide(); Y.one(this.SELECTORS.CONNECTION_OK).show(); this.savefailures = this.FIRST_SUCCESSFUL_SAVE; } else if (this.savefailures === this.FIRST_SUCCESSFUL_SAVE) { Y.one(this.SELECTORS.CONNECTION_OK).hide(); this.savefailures = 0; } }, save_failed: function() { Y.log('Save failed.', 'debug', 'moodle-mod_quiz-autosave'); this.save_transaction = null; // We want to retry soon. this.start_save_timer(); this.savefailures = Math.max(1, this.savefailures + 1); if (this.savefailures === this.FAILURES_BEFORE_NOTIFY) { Y.one(this.SELECTORS.CONNECTION_ERROR).show(); Y.one(this.SELECTORS.CONNECTION_OK).hide(); } }, /** * Inform the user that their answers have been saved. * * @method update_saved_time_display */ update_saved_time_display: function() { // We fetch the current language's preferred time format from the language pack. require(['core/user_date', 'core/notification'], function(UserDate, Notification) { UserDate.get([{ timestamp: Math.floor(Date.now() / 1000), format: M.util.get_string('strftimedatetimeshortaccurate', 'langconfig'), }]).then(function(dateStrs) { var infoDiv = Y.one('#mod_quiz_navblock .othernav .autosave_info'); infoDiv.set('text', M.util.get_string('lastautosave', 'quiz', dateStrs[0])); infoDiv.show(); return; }).catch(Notification.exception); }); }, is_time_nearly_over: function() { return M.mod_quiz.timer && M.mod_quiz.timer.endtime && (new Date().getTime() + 2 * this.delay) > M.mod_quiz.timer.endtime; }, stop_autosaving: function() { this.cancel_delay(); this.delay_timer = true; if (this.save_transaction) { this.save_transaction.abort(); } } }; PK9B\@yui/src/dragdrop/build.jsonnu[{ "name": "moodle-mod_quiz-dragdrop", "builds": { "moodle-mod_quiz-dragdrop": { "jsfiles": [ "dragdrop.js", "section.js", "resource.js" ] } } } PK9B\&#yui/src/dragdrop/meta/dragdrop.jsonnu[{ "moodle-mod_quiz-dragdrop": { "requires": [ "base", "node", "io", "dom", "dd", "dd-scroll", "moodle-core-dragdrop", "moodle-core-notification", "moodle-mod_quiz-quizbase", "moodle-mod_quiz-util-base", "moodle-mod_quiz-util-page", "moodle-mod_quiz-util-slot", "moodle-course-util" ] } } PK9B\`??yui/src/dragdrop/js/dragdrop.jsnu[/* eslint-disable no-unused-vars */ /** * Drag and Drop for Quiz sections and slots. * * @module moodle-mod-quiz-dragdrop */ var CSS = { ACTIONAREA: '.actions', ACTIVITY: 'activity', ACTIVITYINSTANCE: 'activityinstance', CONTENT: 'content', COURSECONTENT: 'mod-quiz-edit-content', EDITINGMOVE: 'editing_move', ICONCLASS: 'iconsmall', JUMPMENU: 'jumpmenu', LEFT: 'left', LIGHTBOX: 'lightbox', MOVEDOWN: 'movedown', MOVEUP: 'moveup', PAGE: 'page', PAGECONTENT: 'page-content', RIGHT: 'right', SECTION: 'slots', SECTIONADDMENUS: 'section_add_menus', SECTIONHANDLE: 'section-handle', SLOTS: 'slots', SUMMARY: 'summary', SECTIONDRAGGABLE: 'sectiondraggable' }, // The CSS selectors we use. SELECTOR = { PAGE: 'li.page', SLOT: 'li.slot' }; PK9B\mMyui/src/dragdrop/js/resource.jsnu[/** * Resource drag and drop. * * @class M.course.dragdrop.resource * @constructor * @extends M.core.dragdrop */ var DRAGRESOURCE = function() { DRAGRESOURCE.superclass.constructor.apply(this, arguments); }; Y.extend(DRAGRESOURCE, M.core.dragdrop, { initializer: function() { // Set group for parent class this.groups = ['resource']; this.samenodeclass = CSS.ACTIVITY; this.parentnodeclass = CSS.SECTION; this.samenodelabel = { identifier: 'dragtoafter', component: 'quiz' }; this.parentnodelabel = { identifier: 'dragtostart', component: 'quiz' }; // Go through all sections this.setup_for_section(); // Initialise drag & drop for all resources/activities var nodeselector = 'li.' + CSS.ACTIVITY; var del = new Y.DD.Delegate({ container: '.' + CSS.COURSECONTENT, nodes: nodeselector, target: true, handles: ['.' + CSS.EDITINGMOVE], dragConfig: {groups: this.groups} }); del.dd.plug(Y.Plugin.DDProxy, { // Don't move the node at the end of the drag moveOnEnd: false, cloneNode: true }); del.dd.plug(Y.Plugin.DDConstrained, { // Keep it inside the .mod-quiz-edit-content constrain: '#' + CSS.SLOTS }); del.dd.plug(Y.Plugin.DDWinScroll); M.mod_quiz.quizbase.register_module(this); M.mod_quiz.dragres = this; }, /** * Apply dragdrop features to the specified selector or node that refers to section(s) * * @method setup_for_section * @param {String} baseselector The CSS selector or node to limit scope to */ setup_for_section: function() { Y.Node.all('.mod-quiz-edit-content ul.slots ul.section').each(function(resources) { resources.setAttribute('data-draggroups', this.groups.join(' ')); // Define empty ul as droptarget, so that item could be moved to empty list new Y.DD.Drop({ node: resources, groups: this.groups, padding: '20 0 20 0' }); // Initialise each resource/activity in this section this.setup_for_resource('li.activity'); }, this); }, /** * Apply dragdrop features to the specified selector or node that refers to resource(s) * * @method setup_for_resource * @param {String} baseselector The CSS selector or node to limit scope to */ setup_for_resource: function(baseselector) { Y.Node.all(baseselector).each(function(resourcesnode) { // Replace move icons var move = resourcesnode.one('a.' + CSS.EDITINGMOVE); if (move) { var resourcedraghandle = this.get_drag_handle(M.util.get_string('move', 'moodle'), CSS.EDITINGMOVE, CSS.ICONCLASS, true); move.replace(resourcedraghandle); } }, this); }, drag_start: function(e) { // Get our drag object var drag = e.target; drag.get('dragNode').setContent(drag.get('node').get('innerHTML')); drag.get('dragNode').all('.icon').setStyle('vertical-align', 'baseline'); }, drag_dropmiss: function(e) { // Missed the target, but we assume the user intended to drop it // on the last ghost node location, e.drag and e.drop should be // prepared by global_drag_dropmiss parent so simulate drop_hit(e). this.drop_hit(e); }, drop_hit: function(e) { var drag = e.drag; // Get a reference to our drag node var dragnode = drag.get('node'); // Add spinner if it not there var actionarea = dragnode.one(CSS.ACTIONAREA); var spinner = M.util.add_spinner(Y, actionarea); var params = {}; // Handle any variables which we must pass back through to var pageparams = this.get('config').pageparams; var varname; for (varname in pageparams) { params[varname] = pageparams[varname]; } // Prepare request parameters params.sesskey = M.cfg.sesskey; params.courseid = this.get('courseid'); params.quizid = this.get('quizid'); params['class'] = 'resource'; params.field = 'move'; params.id = Number(Y.Moodle.mod_quiz.util.slot.getId(dragnode)); params.sectionId = Y.Moodle.core_course.util.section.getId(dragnode.ancestor('li.section', true)); var previousslot = dragnode.previous(SELECTOR.SLOT); if (previousslot) { params.previousid = Number(Y.Moodle.mod_quiz.util.slot.getId(previousslot)); } var previouspage = dragnode.previous(SELECTOR.PAGE); if (previouspage) { params.page = Number(Y.Moodle.mod_quiz.util.page.getId(previouspage)); } // Do AJAX request var uri = M.cfg.wwwroot + this.get('ajaxurl'); Y.io(uri, { method: 'POST', data: params, on: { start: function() { this.lock_drag_handle(drag, CSS.EDITINGMOVE); spinner.show(); }, success: function(tid, response) { var responsetext = Y.JSON.parse(response.responseText); var params = {element: dragnode, visible: responsetext.visible}; M.mod_quiz.quizbase.invoke_function('set_visibility_resource_ui', params); this.unlock_drag_handle(drag, CSS.EDITINGMOVE); window.setTimeout(function() { spinner.hide(); }, 250); M.mod_quiz.resource_toolbox.reorganise_edit_page(); }, failure: function(tid, response) { this.ajax_failure(response); this.unlock_drag_handle(drag, CSS.SECTIONHANDLE); spinner.hide(); window.location.reload(true); } }, context: this }); }, global_drop_over: function(e) { // Overriding parent method so we can stop the slots being dragged before the first page node. // Check that drop object belong to correct group. if (!e.drop || !e.drop.inGroup(this.groups)) { return; } // Get a reference to our drag and drop nodes. var drag = e.drag.get('node'), drop = e.drop.get('node'); // Save last drop target for the case of missed target processing. this.lastdroptarget = e.drop; // Are we dropping within the same parent node? if (drop.hasClass(this.samenodeclass)) { var where; if (this.goingup) { where = "before"; } else { where = "after"; } drop.insert(drag, where); } else if ((drop.hasClass(this.parentnodeclass) || drop.test('[data-droptarget="1"]')) && !drop.contains(drag)) { // We are dropping on parent node and it is empty if (this.goingup) { drop.append(drag); } else { drop.prepend(drag); } } this.drop_over(e); } }, { NAME: 'mod_quiz-dragdrop-resource', ATTRS: { courseid: { value: null }, quizid: { value: null }, ajaxurl: { value: 0 }, config: { value: 0 } } }); M.mod_quiz = M.mod_quiz || {}; M.mod_quiz.init_resource_dragdrop = function(params) { new DRAGRESOURCE(params); }; PK9B\z)l&l&yui/src/dragdrop/js/section.jsnu[/** * Section drag and drop. * * @class M.mod_quiz.dragdrop.section * @constructor * @extends M.core.dragdrop */ var DRAGSECTION = function() { DRAGSECTION.superclass.constructor.apply(this, arguments); }; Y.extend(DRAGSECTION, M.core.dragdrop, { sectionlistselector: null, initializer: function() { // Set group for parent class this.groups = [CSS.SECTIONDRAGGABLE]; this.samenodeclass = 'section'; this.parentnodeclass = 'slots'; // Check if we are in single section mode if (Y.Node.one('.' + CSS.JUMPMENU)) { return false; } // Initialise sections dragging this.sectionlistselector = 'li.section'; if (this.sectionlistselector) { this.sectionlistselector = '.' + CSS.COURSECONTENT + ' ' + this.sectionlistselector; this.setup_for_section(this.sectionlistselector); // Make each li element in the lists of sections draggable var del = new Y.DD.Delegate({ container: '.' + CSS.COURSECONTENT, nodes: '.' + CSS.SECTIONDRAGGABLE, target: true, handles: ['.' + CSS.LEFT], dragConfig: {groups: this.groups} }); del.dd.plug(Y.Plugin.DDProxy, { // Don't move the node at the end of the drag moveOnEnd: false }); del.dd.plug(Y.Plugin.DDConstrained, { // Keep it inside the .mod-quiz-edit-content constrain: '#' + CSS.PAGECONTENT, stickY: true }); del.dd.plug(Y.Plugin.DDWinScroll); } }, /** * Apply dragdrop features to the specified selector or node that refers to section(s) * * @method setup_for_section * @param {String} baseselector The CSS selector or node to limit scope to */ setup_for_section: function(baseselector) { Y.Node.all(baseselector).each(function(sectionnode) { // Determine the section ID var sectionid = Y.Moodle.core_course.util.section.getId(sectionnode); // We skip the top section as it is not draggable if (sectionid > 0) { // Remove move icons var movedown = sectionnode.one('.' + CSS.RIGHT + ' a.' + CSS.MOVEDOWN); var moveup = sectionnode.one('.' + CSS.RIGHT + ' a.' + CSS.MOVEUP); // Add dragger icon var title = M.util.get_string('movesection', 'moodle', sectionid); var cssleft = sectionnode.one('.' + CSS.LEFT); if ((movedown || moveup) && cssleft) { cssleft.setStyle('cursor', 'move'); cssleft.appendChild(this.get_drag_handle(title, CSS.SECTIONHANDLE, 'icon', true)); if (moveup) { moveup.remove(); } if (movedown) { movedown.remove(); } // This section can be moved - add the class to indicate this to Y.DD. sectionnode.addClass(CSS.SECTIONDRAGGABLE); } } }, this); }, /* * Drag-dropping related functions */ drag_start: function(e) { // Get our drag object var drag = e.target; // Creat a dummy structure of the outer elemnents for clean styles application var containernode = Y.Node.create('
    '); var sectionnode = Y.Node.create('
      '); sectionnode.setStyle('margin', 0); sectionnode.setContent(drag.get('node').get('innerHTML')); containernode.appendChild(sectionnode); drag.get('dragNode').setContent(containernode); drag.get('dragNode').addClass(CSS.COURSECONTENT); }, drag_dropmiss: function(e) { // Missed the target, but we assume the user intended to drop it // on the last last ghost node location, e.drag and e.drop should be // prepared by global_drag_dropmiss parent so simulate drop_hit(e). this.drop_hit(e); }, get_section_index: function(node) { var sectionlistselector = '.' + CSS.COURSECONTENT + ' li.section', sectionList = Y.all(sectionlistselector), nodeIndex = sectionList.indexOf(node), zeroIndex = sectionList.indexOf(Y.one('#section-0')); return (nodeIndex - zeroIndex); }, drop_hit: function(e) { var drag = e.drag; // Get references to our nodes and their IDs. var dragnode = drag.get('node'), dragnodeid = Y.Moodle.core_course.util.section.getId(dragnode), loopstart = dragnodeid, dropnodeindex = this.get_section_index(dragnode), loopend = dropnodeindex; if (dragnodeid === dropnodeindex) { Y.log("Skipping move - same location moving " + dragnodeid + " to " + dropnodeindex, 'debug', 'moodle-mod_quiz-dragdrop'); return; } Y.log("Moving from position " + dragnodeid + " to position " + dropnodeindex, 'debug', 'moodle-mod_quiz-dragdrop'); if (loopstart > loopend) { // If we're going up, we need to swap the loop order // because loops can't go backwards. loopstart = dropnodeindex; loopend = dragnodeid; } // Get the list of nodes. drag.get('dragNode').removeClass(CSS.COURSECONTENT); var sectionlist = Y.Node.all(this.sectionlistselector); // Add a lightbox if it's not there. var lightbox = M.util.add_lightbox(Y, dragnode); // Handle any variables which we must pass via AJAX. var params = {}, pageparams = this.get('config').pageparams, varname; for (varname in pageparams) { if (!pageparams.hasOwnProperty(varname)) { continue; } params[varname] = pageparams[varname]; } // Prepare request parameters params.sesskey = M.cfg.sesskey; params.courseid = this.get('courseid'); params.quizid = this.get('quizid'); params['class'] = 'section'; params.field = 'move'; params.id = dragnodeid; params.value = dropnodeindex; // Perform the AJAX request. var uri = M.cfg.wwwroot + this.get('ajaxurl'); Y.io(uri, { method: 'POST', data: params, on: { start: function() { lightbox.show(); }, success: function(tid, response) { // Update section titles, we can't simply swap them as // they might have custom title try { var responsetext = Y.JSON.parse(response.responseText); if (responsetext.error) { new M.core.ajaxException(responsetext); } M.mod_quiz.edit.process_sections(Y, sectionlist, responsetext, loopstart, loopend); } catch (e) { // Ignore. } // Update all of the section IDs - first unset them, then set them // to avoid duplicates in the DOM. var index; // Classic bubble sort algorithm is applied to the section // nodes between original drag node location and the new one. var swapped = false; do { swapped = false; for (index = loopstart; index <= loopend; index++) { if (Y.Moodle.core_course.util.section.getId(sectionlist.item(index - 1)) > Y.Moodle.core_course.util.section.getId(sectionlist.item(index))) { Y.log("Swapping " + Y.Moodle.core_course.util.section.getId(sectionlist.item(index - 1)) + " with " + Y.Moodle.core_course.util.section.getId(sectionlist.item(index)), "debug", "moodle-mod_quiz-dragdrop"); // Swap section id. var sectionid = sectionlist.item(index - 1).get('id'); sectionlist.item(index - 1).set('id', sectionlist.item(index).get('id')); sectionlist.item(index).set('id', sectionid); // See what format needs to swap. M.mod_quiz.edit.swap_sections(Y, index - 1, index); // Update flag. swapped = true; } } loopend = loopend - 1; } while (swapped); window.setTimeout(function() { lightbox.hide(); }, 250); }, failure: function(tid, response) { this.ajax_failure(response); lightbox.hide(); } }, context: this }); } }, { NAME: 'mod_quiz-dragdrop-section', ATTRS: { courseid: { value: null }, quizid: { value: null }, ajaxurl: { value: 0 }, config: { value: 0 } } }); M.mod_quiz = M.mod_quiz || {}; M.mod_quiz.init_section_dragdrop = function(params) { new DRAGSECTION(params); }; PK9B\BAAyui/src/util/build.jsonnu[{ "name": "moodle-mod_quiz-util", "builds": { "moodle-mod_quiz-util-base": { "jsfiles": [ "base.js" ] }, "moodle-mod_quiz-util-slot": { "jsfiles": [ "slot.js" ] } , "moodle-mod_quiz-util-page": { "jsfiles": [ "page.js" ] } } } PK9B\kU%%yui/src/util/meta/util.jsonnu[{ "moodle-mod_quiz-util": { "requires": [ "node", "moodle-core-actionmenu" ], "use": [ "moodle-mod_quiz-util-base" ], "submodules": { "moodle-mod_quiz-util-base": { }, "moodle-mod_quiz-util-slot": { "requires": [ "node", "moodle-mod_quiz-util-base" ] }, "moodle-mod_quiz-util-page": { "requires": [ "node", "moodle-mod_quiz-util-base" ] } } } } PK9B\ja88yui/src/util/js/slot.jsnu[/** * A collection of utility classes for use with slots. * * @module moodle-mod_quiz-util * @submodule moodle-mod_quiz-util-slot */ Y.namespace('Moodle.mod_quiz.util.slot'); /** * A collection of utility classes for use with slots. * * @class Moodle.mod_quiz.util.slot * @static */ Y.Moodle.mod_quiz.util.slot = { CSS: { SLOT: 'slot', QUESTIONTYPEDESCRIPTION: 'qtype_description', CANNOT_DEPEND: 'question_dependency_cannot_depend' }, CONSTANTS: { SLOTIDPREFIX: 'slot-', QUESTION: M.util.get_string('question', 'moodle') }, SELECTORS: { SLOT: 'li.slot', INSTANCENAME: '.instancename', NUMBER: 'span.slotnumber', PAGECONTENT: 'div#page-content', PAGEBREAK: 'span.page_split_join_wrapper', ICON: '.icon', QUESTIONTYPEDESCRIPTION: '.qtype_description', SECTIONUL: 'ul.section', DEPENDENCY_WRAPPER: '.question_dependency_wrapper', DEPENDENCY_LINK: '.question_dependency_wrapper .cm-edit-action', DEPENDENCY_ICON: '.question_dependency_wrapper .icon' }, /** * Retrieve the slot item from one of it's child Nodes. * * @method getSlotFromComponent * @param slotcomponent {Node} The component Node. * @return {Node|null} The Slot Node. */ getSlotFromComponent: function(slotcomponent) { return Y.one(slotcomponent).ancestor(this.SELECTORS.SLOT, true); }, /** * Determines the slot ID for the provided slot. * * @method getId * @param slot {Node} The slot to find an ID for. * @return {Number|false} The ID of the slot in question or false if no ID was found. */ getId: function(slot) { // We perform a simple substitution operation to get the ID. var id = slot.get('id').replace( this.CONSTANTS.SLOTIDPREFIX, ''); // Attempt to validate the ID. id = parseInt(id, 10); if (typeof id === 'number' && isFinite(id)) { return id; } return false; }, /** * Determines the slot name for the provided slot. * * @method getName * @param slot {Node} The slot to find a name for. * @return {string|false} The name of the slot in question or false if no ID was found. */ getName: function(slot) { var instance = slot.one(this.SELECTORS.INSTANCENAME); if (instance) { return instance.get('firstChild').get('data'); } return null; }, /** * Determines the slot number for the provided slot. * * @method getNumber * @param slot {Node} The slot to find the number for. * @return {Number|false} The number of the slot in question or false if no number was found. */ getNumber: function(slot) { if (!slot) { return false; } // We perform a simple substitution operation to get the number. var number = slot.one(this.SELECTORS.NUMBER).get('text').replace( this.CONSTANTS.QUESTION, ''); // Attempt to validate the ID. number = parseInt(number, 10); if (typeof number === 'number' && isFinite(number)) { return number; } return false; }, /** * Updates the slot number for the provided slot. * * @method setNumber * @param slot {Node} The slot to update the number for. * @return void */ setNumber: function(slot, number) { var numbernode = slot.one(this.SELECTORS.NUMBER); numbernode.setHTML('' + this.CONSTANTS.QUESTION + ' ' + number); }, /** * Returns a list of all slot elements on the page. * * @method getSlots * @return {node[]} An array containing slot nodes. */ getSlots: function() { return Y.all(this.SELECTORS.PAGECONTENT + ' ' + this.SELECTORS.SECTIONUL + ' ' + this.SELECTORS.SLOT); }, /** * Returns a list of all slot elements on the page that have numbers. Excudes description questions. * * @method getSlots * @return {node[]} An array containing slot nodes. */ getNumberedSlots: function() { var selector = this.SELECTORS.PAGECONTENT + ' ' + this.SELECTORS.SECTIONUL; selector += ' ' + this.SELECTORS.SLOT + ':not(' + this.SELECTORS.QUESTIONTYPEDESCRIPTION + ')'; return Y.all(selector); }, /** * Returns the previous slot to the given slot. * * @method getPrevious * @param slot Slot node * @return {node|false} The previous slot node or false. */ getPrevious: function(slot) { return slot.previous(this.SELECTORS.SLOT); }, /** * Returns the previous numbered slot to the given slot. * * Ignores slots containing description question types. * * @method getPrevious * @param slot Slot node * @return {node|false} The previous slot node or false. */ getPreviousNumbered: function(slot) { var previous = slot.previous(this.SELECTORS.SLOT + ':not(' + this.SELECTORS.QUESTIONTYPEDESCRIPTION + ')'); if (previous) { return previous; } var section = slot.ancestor('li.section').previous('li.section'); while (section) { var questions = section.all(this.SELECTORS.SLOT + ':not(' + this.SELECTORS.QUESTIONTYPEDESCRIPTION + ')'); if (questions.size() > 0) { return questions.item(questions.size() - 1); } section = section.previous('li.section'); } return false; }, /** * Reset the order of the numbers given to each slot. * * @method reorderSlots * @return void */ reorderSlots: function() { // Get list of slot nodes. var slots = this.getSlots(); // Loop through slots incrementing the number each time. slots.each(function(slot) { if (!Y.Moodle.mod_quiz.util.page.getPageFromSlot(slot)) { // Move the next page to the front. var nextpage = slot.next(Y.Moodle.mod_quiz.util.page.SELECTORS.PAGE); slot.swap(nextpage); } var previousSlot = this.getPreviousNumbered(slot), previousslotnumber = 0; if (slot.hasClass(this.CSS.QUESTIONTYPEDESCRIPTION)) { return; } if (previousSlot) { previousslotnumber = this.getNumber(previousSlot); } // Set slot number. this.setNumber(slot, previousslotnumber + 1); }, this); }, /** * Add class only-has-one-slot to those sections that need it. * * @method updateOneSlotSections * @return void */ updateOneSlotSections: function() { Y.all('.mod-quiz-edit-content ul.slots li.section').each(function(section) { if (section.all(this.SELECTORS.SLOT).size() > 1) { section.removeClass('only-has-one-slot'); } else { section.addClass('only-has-one-slot'); } }, this); }, /** * Remove a slot and related elements from the list of slots. * * @method remove * @param slot Slot node * @return void */ remove: function(slot) { var page = Y.Moodle.mod_quiz.util.page.getPageFromSlot(slot); slot.remove(); // Is the page empty. if (!Y.Moodle.mod_quiz.util.page.isEmpty(page)) { return; } // If so remove it. Including add menu and page break. Y.Moodle.mod_quiz.util.page.remove(page); }, /** * Returns a list of all page break elements on the page. * * @method getPageBreaks * @return {node[]} An array containing page break nodes. */ getPageBreaks: function() { var selector = this.SELECTORS.PAGECONTENT + ' ' + this.SELECTORS.SECTIONUL; selector += ' ' + this.SELECTORS.SLOT + this.SELECTORS.PAGEBREAK; return Y.all(selector); }, /** * Retrieve the page break element item from the given slot. * * @method getPageBreak * @param slot Slot node * @return {Node|null} The Page Break Node. */ getPageBreak: function(slot) { return Y.one(slot).one(this.SELECTORS.PAGEBREAK); }, /** * Add a page break and related elements to the list of slots. * * @method addPageBreak * @param beforenode Int | Node | HTMLElement | String to add * @return pagebreak PageBreak node */ addPageBreak: function(slot) { var nodetext = M.mod_quiz.resource_toolbox.get('config').addpageiconhtml; nodetext = nodetext.replace('%%SLOT%%', this.getNumber(slot)); var pagebreak = Y.Node.create(nodetext); slot.one('div').insert(pagebreak, 'after'); return pagebreak; }, /** * Remove a pagebreak from the given slot. * * @method removePageBreak * @param slot Slot node * @return boolean */ removePageBreak: function(slot) { var pagebreak = this.getPageBreak(slot); if (!pagebreak) { return false; } pagebreak.remove(); return true; }, /** * Reorder each pagebreak by iterating through each related slot. * * @method reorderPageBreaks * @return void */ reorderPageBreaks: function() { // Get list of slot nodes. var slots = this.getSlots(); var slotnumber = 0; // Loop through slots incrementing the number each time. slots.each(function(slot, key) { slotnumber++; var pagebreak = this.getPageBreak(slot); var nextitem = slot.next('li.activity'); if (!nextitem) { // Last slot in a section. Should not have an icon. return; } // No pagebreak and not last slot. Add one. if (!pagebreak) { pagebreak = this.addPageBreak(slot); } // Remove last page break if there is one. if (pagebreak && key === slots.size() - 1) { this.removePageBreak(slot); } // Get page break anchor element. var pagebreaklink = pagebreak.get('childNodes').item(0); // Get the correct title. var action = ''; var iconname = ''; if (Y.Moodle.mod_quiz.util.page.isPage(nextitem)) { action = 'removepagebreak'; iconname = 'e/remove_page_break'; } else { action = 'addpagebreak'; iconname = 'e/insert_page_break'; } // Update the link and image titles pagebreaklink.set('title', M.util.get_string(action, 'quiz')); pagebreaklink.setData('action', action); // Update the image title. var icon = pagebreaklink.one(this.SELECTORS.ICON); icon.set('title', M.util.get_string(action, 'quiz')); icon.set('alt', M.util.get_string(action, 'quiz')); // Update the image src. icon.set('src', M.util.image_url(iconname)); // Get anchor url parameters as an associative array. var params = Y.QueryString.parse(pagebreaklink.get('href')); // Update slot number. params.slot = slotnumber; // Create the new url. var newurl = ''; for (var index in params) { if (newurl.length) { newurl += "&"; } newurl += index + "=" + params[index]; } // Update the anchor. pagebreaklink.set('href', newurl); }, this); }, /** * Update the dependency icons. * * @method updateAllDependencyIcons * @return void */ updateAllDependencyIcons: function() { // Get list of slot nodes. var slots = this.getSlots(), slotnumber = 0, previousslot = null; // Loop through slots incrementing the number each time. slots.each(function(slot) { slotnumber++; if (slotnumber == 1 || previousslot.getData('canfinish') === '0') { slot.one(this.SELECTORS.DEPENDENCY_WRAPPER).addClass(this.CSS.CANNOT_DEPEND); } else { slot.one(this.SELECTORS.DEPENDENCY_WRAPPER).removeClass(this.CSS.CANNOT_DEPEND); } this.updateDependencyIcon(slot, null); previousslot = slot; }, this); }, /** * Update the slot icon to indicate the new requiresprevious state. * * @method slot Slot node * @method requiresprevious Whether this node now requires the previous one. * @return void */ updateDependencyIcon: function(slot, requiresprevious) { var link = slot.one(this.SELECTORS.DEPENDENCY_LINK); var icon = slot.one(this.SELECTORS.DEPENDENCY_ICON); var previousSlot = this.getPrevious(slot); var a = {thisq: this.getNumber(slot)}; if (previousSlot) { a.previousq = this.getNumber(previousSlot); } if (requiresprevious === null) { requiresprevious = link.getData('action') === 'removedependency'; } if (requiresprevious) { link.set('title', M.util.get_string('questiondependencyremove', 'quiz', a)); link.setData('action', 'removedependency'); window.require(['core/templates'], function(Templates) { Templates.renderPix('t/locked', 'core', M.util.get_string('questiondependsonprevious', 'quiz')).then( function(html) { icon.replace(html); } ); }); } else { link.set('title', M.util.get_string('questiondependencyadd', 'quiz', a)); link.setData('action', 'adddependency'); window.require(['core/templates'], function(Templates) { Templates.renderPix('t/unlocked', 'core', M.util.get_string('questiondependencyfree', 'quiz')).then( function(html) { icon.replace(html); } ); }); } } }; PK9B\לMyui/src/util/js/base.jsnu[/** * The Moodle.mod_quiz.util classes provide quiz-related utility functions. * * @module moodle-mod_quiz-util * @main */ Y.namespace('Moodle.mod_quiz.util'); /** * A collection of general utility functions for use in quiz. * * @class Moodle.mod_quiz.util * @static */ PK9B\R((yui/src/util/js/page.jsnu[/* global YUI */ /** * A collection of utility classes for use with pages. * * @module moodle-mod_quiz-util * @submodule moodle-mod_quiz-util-page */ Y.namespace('Moodle.mod_quiz.util.page'); /** * A collection of utility classes for use with pages. * * @class Moodle.mod_quiz.util.page * @static */ Y.Moodle.mod_quiz.util.page = { CSS: { PAGE: 'page' }, CONSTANTS: { ACTIONMENUIDPREFIX: 'action-menu-', ACTIONMENUBARIDSUFFIX: '-menubar', ACTIONMENUMENUIDSUFFIX: '-menu', PAGEIDPREFIX: 'page-', PAGENUMBERPREFIX: M.util.get_string('page', 'moodle') + ' ' }, SELECTORS: { ACTIONMENU: 'div.moodle-actionmenu', ACTIONMENUBAR: '.menubar', ACTIONMENUMENU: '.menu', ADDASECTION: '[data-action="addasection"]', PAGE: 'li.page', INSTANCENAME: '.instancename', NUMBER: 'h4' }, /** * Retrieve the page item from one of it's child Nodes. * * @method getPageFromComponent * @param pagecomponent {Node} The component Node. * @return {Node|null} The Page Node. */ getPageFromComponent: function(pagecomponent) { return Y.one(pagecomponent).ancestor(this.SELECTORS.PAGE, true); }, /** * Retrieve the page item from one of it's previous siblings. * * @method getPageFromSlot * @param pagecomponent {Node} The component Node. * @return {Node|null} The Page Node. */ getPageFromSlot: function(slot) { return Y.one(slot).previous(this.SELECTORS.PAGE); }, /** * Returns the page ID for the provided page. * * @method getId * @param page {Node} The page to find an ID for. * @return {Number|false} The ID of the page in question or false if no ID was found. */ getId: function(page) { // We perform a simple substitution operation to get the ID. var id = page.get('id').replace( this.CONSTANTS.PAGEIDPREFIX, ''); // Attempt to validate the ID. id = parseInt(id, 10); if (typeof id === 'number' && isFinite(id)) { return id; } return false; }, /** * Updates the page id for the provided page. * * @method setId * @param page {Node} The page to update the number for. * @param id int The id value. * @return void */ setId: function(page, id) { page.set('id', this.CONSTANTS.PAGEIDPREFIX + id); }, /** * Determines the page name for the provided page. * * @method getName * @param page {Node} The page to find a name for. * @return {string|false} The name of the page in question or false if no ID was found. */ getName: function(page) { var instance = page.one(this.SELECTORS.INSTANCENAME); if (instance) { return instance.get('firstChild').get('data'); } return null; }, /** * Determines the page number for the provided page. * * @method getNumber * @param page {Node} The page to find a number for. * @return {Number|false} The number of the page in question or false if no number was found. */ getNumber: function(page) { // We perform a simple substitution operation to get the number. var number = page.one(this.SELECTORS.NUMBER).get('text').replace( this.CONSTANTS.PAGENUMBERPREFIX, ''); // Attempt to validate the ID. number = parseInt(number, 10); if (typeof number === 'number' && isFinite(number)) { return number; } return false; }, /** * Updates the page number for the provided page. * * @method setNumber * @param page {Node} The page to update the number for. * @return void */ setNumber: function(page, number) { page.one(this.SELECTORS.NUMBER).set('text', this.CONSTANTS.PAGENUMBERPREFIX + number); }, /** * Returns a list of all page elements. * * @method getPages * @return {node[]} An array containing page nodes. */ getPages: function() { return Y.all(Y.Moodle.mod_quiz.util.slot.SELECTORS.PAGECONTENT + ' ' + Y.Moodle.mod_quiz.util.slot.SELECTORS.SECTIONUL + ' ' + this.SELECTORS.PAGE); }, /** * Is the given element a page element? * * @method isPage * @param page Page node * @return boolean */ isPage: function(page) { if (!page) { return false; } return page.hasClass(this.CSS.PAGE); }, /** * Does the page have atleast one slot? * * @method isEmpty * @param page Page node * @return boolean */ isEmpty: function(page) { var activity = page.next('li.activity'); if (!activity) { return true; } return !activity.hasClass('slot'); }, /** * Add a page and related elements to the list of slots. * * @method add * @param beforenode Int | Node | HTMLElement | String to add * @return page Page node */ add: function(beforenode) { var pagenumber = this.getNumber(this.getPageFromSlot(beforenode)) + 1; var pagehtml = M.mod_quiz.resource_toolbox.get('config').pagehtml; // Normalise the page number. pagehtml = pagehtml.replace(/%%PAGENUMBER%%/g, pagenumber); // Create the page node. var page = Y.Node.create(pagehtml); // Assign is as a drop target. YUI().use('dd-drop', function(Y) { var drop = new Y.DD.Drop({ node: page, groups: M.mod_quiz.dragres.groups }); page.drop = drop; }); // Insert in the correct place. beforenode.insert(page, 'after'); // Enhance the add menu to make if fully visible and clickable. if (typeof M.core.actionmenu !== "undefined") { M.core.actionmenu.newDOMNode(page); } return page; }, /** * Remove a page and related elements from the list of slots. * * @method remove * @param page Page node * @return void */ remove: function(page, keeppagebreak) { // Remove page break from previous slot. var previousslot = page.previous(Y.Moodle.mod_quiz.util.slot.SELECTORS.SLOT); if (!keeppagebreak && previousslot) { Y.Moodle.mod_quiz.util.slot.removePageBreak(previousslot); } page.remove(); }, /** * Reset the order of the numbers given to each page. * * @method reorderPages * @return void */ reorderPages: function() { // Get list of page nodes. var pages = this.getPages(); var currentpagenumber = 0; // Loop through pages incrementing the number each time. pages.each(function(page) { // Is the page empty? if (this.isEmpty(page)) { var keeppagebreak = page.next('li.slot') ? true : false; this.remove(page, keeppagebreak); return; } currentpagenumber++; // Set page number. this.setNumber(page, currentpagenumber); this.setId(page, currentpagenumber); }, this); // Reorder action menus this.reorderActionMenus(); }, /** * Reset the order of the numbers given to each action menu. * * @method reorderActionMenus * @return void */ reorderActionMenus: function() { // Get list of action menu nodes. var actionmenus = this.getActionMenus(); // Loop through pages incrementing the number each time. actionmenus.each(function(actionmenu, key) { var previousActionMenu = actionmenus.item(key - 1), previousActionMenunumber = 0; if (previousActionMenu) { previousActionMenunumber = this.getActionMenuId(previousActionMenu); } var id = previousActionMenunumber + 1; // Set menu id. this.setActionMenuId(actionmenu, id); // Update action-menu-1-menubar var menubar = actionmenu.one(this.SELECTORS.ACTIONMENUBAR); menubar.set('id', this.CONSTANTS.ACTIONMENUIDPREFIX + id + this.CONSTANTS.ACTIONMENUBARIDSUFFIX); // Update action-menu-1-menu var menumenu = actionmenu.one(this.SELECTORS.ACTIONMENUMENU); menumenu.set('id', this.CONSTANTS.ACTIONMENUIDPREFIX + id + this.CONSTANTS.ACTIONMENUMENUIDSUFFIX); // Update the URL of the add-section action. menumenu.one(this.SELECTORS.ADDASECTION).set('href', menumenu.one(this.SELECTORS.ADDASECTION).get('href').replace(/\baddsectionatpage=\d+\b/, 'addsectionatpage=' + id)); }, this); }, /** * Returns a list of all page elements. * * @method getActionMenus * @return {node[]} An array containing page nodes. */ getActionMenus: function() { return Y.all(Y.Moodle.mod_quiz.util.slot.SELECTORS.PAGECONTENT + ' ' + Y.Moodle.mod_quiz.util.slot.SELECTORS.SECTIONUL + ' ' + this.SELECTORS.ACTIONMENU); }, /** * Returns the ID for the provided action menu. * * @method getId * @param actionmenu {Node} The actionmenu to find an ID for. * @return {Number|false} The ID of the actionmenu in question or false if no ID was found. */ getActionMenuId: function(actionmenu) { // We perform a simple substitution operation to get the ID. var id = actionmenu.get('id').replace( this.CONSTANTS.ACTIONMENUIDPREFIX, ''); // Attempt to validate the ID. id = parseInt(id, 10); if (typeof id === 'number' && isFinite(id)) { return id; } return false; }, /** * Updates the page id for the provided page. * * @method setId * @param page {Node} The page to update the number for. * @param id int The id value. * @return void */ setActionMenuId: function(actionmenu, id) { actionmenu.set('id', this.CONSTANTS.ACTIONMENUIDPREFIX + id); } }; PK9B\Ǣ"yui/src/questionchooser/build.jsonnu[{ "name": "moodle-mod_quiz-questionchooser", "builds": { "moodle-mod_quiz-questionchooser": { "jsfiles": [ "questionchooser.js" ] } } } PK9B\^@1yui/src/questionchooser/meta/questionchooser.jsonnu[{ "moodle-mod_quiz-questionchooser": { "requires": [ "moodle-core-chooserdialogue", "moodle-mod_quiz-util", "querystring-parse" ] } } PK9B\^ -yui/src/questionchooser/js/questionchooser.jsnu[var CSS = { ADDNEWQUESTIONBUTTONS: '.menu [data-action="addquestion"]', CREATENEWQUESTION: 'div.createnewquestion', CHOOSERDIALOGUE: 'div.chooserdialoguebody', CHOOSERHEADER: 'div.choosertitle' }; /** * The questionchooser class is responsible for instantiating and displaying the question chooser * when viewing a quiz in editing mode. * * @class questionchooser * @constructor * @protected * @extends M.core.chooserdialogue */ var QUESTIONCHOOSER = function() { QUESTIONCHOOSER.superclass.constructor.apply(this, arguments); }; Y.extend(QUESTIONCHOOSER, M.core.chooserdialogue, { initializer: function() { Y.one('body').delegate('click', this.display_dialogue, CSS.ADDNEWQUESTIONBUTTONS, this); }, display_dialogue: function(e) { e.preventDefault(); var dialogue = Y.one(CSS.CREATENEWQUESTION + ' ' + CSS.CHOOSERDIALOGUE), header = Y.one(CSS.CREATENEWQUESTION + ' ' + CSS.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 parameters = Y.QueryString.parse(e.currentTarget.get('search').substring(1)); var form = this.container.one('form'); this.parameters_to_hidden_input(parameters, form, 'returnurl'); this.parameters_to_hidden_input(parameters, form, 'cmid'); this.parameters_to_hidden_input(parameters, form, 'category'); this.parameters_to_hidden_input(parameters, form, 'addonpage'); this.parameters_to_hidden_input(parameters, form, 'appendqnumstring'); // Display the chooser dialogue. this.display_chooser(e); }, parameters_to_hidden_input: function(parameters, form, name) { var value; if (parameters.hasOwnProperty(name)) { value = parameters[name]; } else { value = ''; } var input = form.one('input[name=' + name + ']'); if (!input) { input = form.appendChild(''); input.set('name', name); } input.set('value', value); } }, { NAME: 'mod_quiz-questionchooser' }); M.mod_quiz = M.mod_quiz || {}; M.mod_quiz.init_questionchooser = function() { M.mod_quiz.question_chooser = new QUESTIONCHOOSER({}); return M.mod_quiz.question_chooser; }; PK9B\d_yui/src/quizbase/build.jsonnu[{ "name": "moodle-mod_quiz-quizbase", "builds": { "moodle-mod_quiz-quizbase": { "jsfiles": [ "quizbase.js" ] } } } PK9B\Bnss#yui/src/quizbase/meta/quizbase.jsonnu[{ "moodle-mod_quiz-quizbase": { "requires": [ "base", "node" ] } } PK9B\0m  yui/src/quizbase/js/quizbase.jsnu[/** * The quizbase class to provide shared functionality to Modules within Moodle. * * @module moodle-mod_quiz-quizbase */ var QUIZBASENAME = 'mod_quiz-quizbase'; var QUIZBASE = function() { QUIZBASE.superclass.constructor.apply(this, arguments); }; /** * The coursebase class to provide shared functionality to Modules within * Moodle. * * @class M.course.coursebase * @constructor */ Y.extend(QUIZBASE, Y.Base, { // Registered Modules registermodules: [], /** * Register a new Javascript Module * * @method register_module * @param {Object} The instantiated module to call functions on * @chainable */ register_module: function(object) { this.registermodules.push(object); return this; }, /** * Invoke the specified function in all registered modules with the given arguments * * @method invoke_function * @param {String} functionname The name of the function to call * @param {mixed} args The argument supplied to the function * @chainable */ invoke_function: function(functionname, args) { var module; for (module in this.registermodules) { if (functionname in this.registermodules[module]) { this.registermodules[module][functionname](args); } } return this; } }, { NAME: QUIZBASENAME, ATTRS: {} }); // Ensure that M.course exists and that coursebase is initialised correctly M.mod_quiz = M.mod_quiz || {}; M.mod_quiz.quizbase = M.mod_quiz.quizbase || new QUIZBASE(); // Abstract functions that needs to be defined per format (course/format/somename/format.js) M.mod_quiz.edit = M.mod_quiz.edit || {}; /** * Swap section (should be defined in format.js if requred) * * @param {YUI} Y YUI3 instance * @param {string} node1 node to swap to * @param {string} node2 node to swap with * @return {NodeList} section list */ M.mod_quiz.edit.swap_sections = function(Y, node1, node2) { var CSS = { COURSECONTENT: 'mod-quiz-edit-content', SECTIONADDMENUS: 'section_add_menus' }; var sectionlist = Y.Node.all('.' + CSS.COURSECONTENT + ' li.section'); // Swap menus. sectionlist.item(node1).one('.' + CSS.SECTIONADDMENUS).swap(sectionlist.item(node2).one('.' + CSS.SECTIONADDMENUS)); }; /** * Process sections after ajax response (should be defined in format.js) * If some response is expected, we pass it over to format, as it knows better * hot to process it. * * @param {YUI} Y YUI3 instance * @param {NodeList} list of sections * @param {array} response ajax response * @param {string} sectionfrom first affected section * @param {string} sectionto last affected section * @return void */ M.mod_quiz.edit.process_sections = function(Y, sectionlist, response, sectionfrom, sectionto) { var CSS = { SECTIONNAME: 'sectionname' }, SELECTORS = { SECTIONLEFTSIDE: '.left .section-handle .icon' }; if (response.action === 'move') { // If moving up swap around 'sectionfrom' and 'sectionto' so the that loop operates. if (sectionfrom > sectionto) { var temp = sectionto; sectionto = sectionfrom; sectionfrom = temp; } // Update titles and move icons in all affected sections. var ele, str, stridx, newstr; for (var i = sectionfrom; i <= sectionto; i++) { // Update section title. sectionlist.item(i).one('.' + CSS.SECTIONNAME).setContent(response.sectiontitles[i]); // Update move icon. ele = sectionlist.item(i).one(SELECTORS.SECTIONLEFTSIDE); str = ele.getAttribute('alt'); stridx = str.lastIndexOf(' '); newstr = str.substr(0, stridx + 1) + i; ele.setAttribute('alt', newstr); ele.setAttribute('title', newstr); // For FireFox as 'alt' is not refreshed. // Remove the current class as section has been moved. sectionlist.item(i).removeClass('current'); } // If there is a current section, apply corresponding class in order to highlight it. if (response.current !== -1) { // Add current class to the required section. sectionlist.item(response.current).addClass('current'); } } }; PK9B\*yui/src/toolboxes/build.jsonnu[{ "name": "moodle-mod_quiz-toolboxes", "builds": { "moodle-mod_quiz-toolboxes": { "jsfiles": [ "toolbox.js", "resource.js", "section.js" ] } } } PK9B\n~;;%yui/src/toolboxes/meta/toolboxes.jsonnu[{ "moodle-mod_quiz-toolboxes": { "requires": [ "base", "node", "event", "event-key", "io", "moodle-mod_quiz-quizbase", "moodle-mod_quiz-util-slot", "moodle-core-notification-ajaxexception" ] } } PK9B\/XUXU yui/src/toolboxes/js/resource.jsnu[/** * Resource and activity toolbox class. * * This class is responsible for managing AJAX interactions with activities and resources * when viewing a quiz in editing mode. * * @module mod_quiz-resource-toolbox * @namespace M.mod_quiz.resource_toolbox */ /** * Resource and activity toolbox class. * * This is a class extending TOOLBOX containing code specific to resources * * This class is responsible for managing AJAX interactions with activities and resources * when viewing a quiz in editing mode. * * @class resources * @constructor * @extends M.course.toolboxes.toolbox */ var RESOURCETOOLBOX = function() { RESOURCETOOLBOX.superclass.constructor.apply(this, arguments); }; Y.extend(RESOURCETOOLBOX, TOOLBOX, { /** * An Array of events added when editing a max mark field. * These should all be detached when editing is complete. * * @property editmaxmarkevents * @protected * @type Array * @protected */ editmaxmarkevents: [], /** * */ NODE_PAGE: 1, NODE_SLOT: 2, NODE_JOIN: 3, /** * Initialize the resource toolbox * * For each activity the commands are updated and a reference to the activity is attached. * This way it doesn't matter where the commands are going to called from they have a reference to the * activity that they relate to. * This is essential as some of the actions are displayed in an actionmenu which removes them from the * page flow. * * This function also creates a single event delegate to manage all AJAX actions for all activities on * the page. * * @method initializer * @protected */ initializer: function() { M.mod_quiz.quizbase.register_module(this); Y.delegate('click', this.handle_data_action, BODY, SELECTOR.ACTIVITYACTION, this); Y.delegate('click', this.handle_data_action, BODY, SELECTOR.DEPENDENCY_LINK, this); this.initialise_select_multiple(); }, /** * Initialize the select multiple options * * Add actions to the buttons that enable multiple slots to be selected and managed at once. * * @method initialise_select_multiple * @protected */ initialise_select_multiple: function() { // Click select multiple button to show the select all options. Y.one(SELECTOR.SELECTMULTIPLEBUTTON).on('click', function(e) { e.preventDefault(); Y.one('body').addClass(CSS.SELECTMULTIPLE); }); // Click cancel button to show the select all options. Y.one(SELECTOR.SELECTMULTIPLECANCELBUTTON).on('click', function(e) { e.preventDefault(); Y.one('body').removeClass(CSS.SELECTMULTIPLE); }); // Assign the delete method to the delete multiple button. Y.delegate('click', this.delete_multiple_action, BODY, SELECTOR.SELECTMULTIPLEDELETEBUTTON, this); }, /** * Handles the delegation event. When this is fired someone has triggered an action. * * Note not all actions will result in an AJAX enhancement. * * @protected * @method handle_data_action * @param {EventFacade} ev The event that was triggered. * @returns {boolean} */ handle_data_action: function(ev) { // We need to get the anchor element that triggered this event. var node = ev.target; if (!node.test('a')) { node = node.ancestor(SELECTOR.ACTIVITYACTION); } // From the anchor we can get both the activity (added during initialisation) and the action being // performed (added by the UI as a data attribute). var action = node.getData('action'), activity = node.ancestor(SELECTOR.ACTIVITYLI); if (!node.test('a') || !action || !activity) { // It wasn't a valid action node. return; } // Switch based upon the action and do the desired thing. switch (action) { case 'editmaxmark': // The user wishes to edit the maxmark of the resource. this.edit_maxmark(ev, node, activity, action); break; case 'delete': // The user is deleting the activity. this.delete_with_confirmation(ev, node, activity, action); break; case 'addpagebreak': case 'removepagebreak': // The user is adding or removing a page break. this.update_page_break(ev, node, activity, action); break; case 'adddependency': case 'removedependency': // The user is adding or removing a dependency between questions. this.update_dependency(ev, node, activity, action); break; default: // Nothing to do here! break; } }, /** * Add a loading icon to the specified activity. * * The icon is added within the action area. * * @method add_spinner * @param {Node} activity The activity to add a loading icon to * @return {Node|null} The newly created icon, or null if the action area was not found. */ add_spinner: function(activity) { var actionarea = activity.one(SELECTOR.ACTIONAREA); if (actionarea) { return M.util.add_spinner(Y, actionarea); } return null; }, /** * Deletes the given activity or resource after confirmation. * * @protected * @method delete_with_confirmation * @param {EventFacade} ev The event that was fired. * @param {Node} button The button that triggered this action. * @param {Node} activity The activity node that this action will be performed on. */ delete_with_confirmation: function(ev, button, activity) { // Prevent the default button action. ev.preventDefault(); // Get the element we're working on. var element = activity; // Create confirm string (different if element has or does not have name) var qtypename = M.util.get_string( 'pluginname', 'qtype_' + element.getAttribute('class').match(/qtype_([^\s]*)/)[1] ); // Create the confirmation dialogue. require(['core/notification'], function(Notification) { Notification.saveCancelPromise( M.util.get_string('confirm', 'moodle'), M.util.get_string('confirmremovequestion', 'quiz', qtypename), M.util.get_string('yes', 'moodle') ).then(function() { var spinner = this.add_spinner(element); var data = { 'class': 'resource', 'action': 'DELETE', 'id': Y.Moodle.mod_quiz.util.slot.getId(element) }; this.send_request(data, spinner, function(response) { if (response.deleted) { // Actually remove the element. Y.Moodle.mod_quiz.util.slot.remove(element); this.reorganise_edit_page(); if (M.core.actionmenu && M.core.actionmenu.instance) { M.core.actionmenu.instance.hideMenu(ev); } } }); return; }.bind(this)).catch(function() { // User cancelled. }); }.bind(this)); }, /** * Finds the section that would become empty if we remove the selected slots. * * @protected * @method find_sections_that_would_become_empty * @returns {String} The name of the first section found */ find_sections_that_would_become_empty: function() { var section; var sectionnodes = Y.all(SELECTOR.SECTIONLI); if (sectionnodes.size() > 1) { sectionnodes.some(function(node) { var sectionname = node.one(SELECTOR.INSTANCESECTION).getContent(); var checked = node.all(SELECTOR.SELECTMULTIPLECHECKBOX + ':checked'); var unchecked = node.all(SELECTOR.SELECTMULTIPLECHECKBOX + ':not(:checked)'); if (!checked.isEmpty() && unchecked.isEmpty()) { section = sectionname; } return section; }); } return section; }, /** * Takes care of what needs to happen when the user clicks on the delete multiple button. * * @protected * @method delete_multiple_action * @param {EventFacade} ev The event that was fired. */ delete_multiple_action: function(ev) { var problemsection = this.find_sections_that_would_become_empty(); if (typeof problemsection !== 'undefined') { require(['core/notification'], function(Notification) { Notification.alert( M.util.get_string('cannotremoveslots', 'quiz'), M.util.get_string('cannotremoveallsectionslots', 'quiz', problemsection) ); }); } else { this.delete_multiple_with_confirmation(ev); } }, /** * Deletes the given activities or resources after confirmation. * * @protected * @method delete_multiple_with_confirmation * @param {EventFacade} ev The event that was fired. */ delete_multiple_with_confirmation: function(ev) { ev.preventDefault(); var ids = ''; var slots = []; Y.all(SELECTOR.SELECTMULTIPLECHECKBOX + ':checked').each(function(node) { var slot = Y.Moodle.mod_quiz.util.slot.getSlotFromComponent(node); ids += ids === '' ? '' : ','; ids += Y.Moodle.mod_quiz.util.slot.getId(slot); slots.push(slot); }); var element = Y.one('div.mod-quiz-edit-content'); // Do nothing if no slots are selected. if (!slots || !slots.length) { return; } require(['core/notification'], function(Notification) { Notification.saveCancelPromise( M.util.get_string('confirm', 'moodle'), M.util.get_string('areyousureremoveselected', 'quiz'), M.util.get_string('yes', 'moodle') ).then(function() { var spinner = this.add_spinner(element); var data = { 'class': 'resource', field: 'deletemultiple', ids: ids }; // Delete items on server. this.send_request(data, spinner, function(response) { // Delete locally if deleted on server. if (response.deleted) { // Actually remove the element. Y.all(SELECTOR.SELECTMULTIPLECHECKBOX + ':checked').each(function(node) { Y.Moodle.mod_quiz.util.slot.remove(node.ancestor('li.activity')); }); // Update the page numbers and sections. this.reorganise_edit_page(); // Remove the select multiple options. Y.one('body').removeClass(CSS.SELECTMULTIPLE); } }); return; }.bind(this)).catch(function() { // User cancelled. }); }.bind(this)); }, /** * Edit the maxmark for the resource * * @protected * @method edit_maxmark * @param {EventFacade} ev The event that was fired. * @param {Node} button The button that triggered this action. * @param {Node} activity The activity node that this action will be performed on. * @param {String} action The action that has been requested. * @return Boolean */ edit_maxmark: function(ev, button, activity) { // Get the element we're working on var instancemaxmark = activity.one(SELECTOR.INSTANCEMAXMARK), instance = activity.one(SELECTOR.ACTIVITYINSTANCE), currentmaxmark = instancemaxmark.get('firstChild'), oldmaxmark = currentmaxmark.get('data'), maxmarktext = oldmaxmark, thisevent, anchor = instancemaxmark, // Grab the anchor so that we can swap it with the edit form. data = { 'class': 'resource', 'field': 'getmaxmark', 'id': Y.Moodle.mod_quiz.util.slot.getId(activity) }; // Prevent the default actions. ev.preventDefault(); this.send_request(data, null, function(response) { if (M.core.actionmenu && M.core.actionmenu.instance) { M.core.actionmenu.instance.hideMenu(ev); } // Try to retrieve the existing string from the server. if (response.instancemaxmark) { maxmarktext = response.instancemaxmark; } // Create the editor and submit button. var editform = Y.Node.create('
      '); var editinstructions = Y.Node.create('') .set('innerHTML', M.util.get_string('edittitleinstructions', 'moodle')); var editor = Y.Node.create('').setAttrs({ 'value': maxmarktext, 'autocomplete': 'off', 'aria-describedby': 'id_editinstructions', 'maxLength': '12', 'size': parseInt(this.get('config').questiondecimalpoints, 10) + 2 }); // Clear the existing content and put the editor in. editform.appendChild(editor); editform.setData('anchor', anchor); instance.insert(editinstructions, 'before'); anchor.replace(editform); // We hide various components whilst editing: activity.addClass(CSS.EDITINGMAXMARK); // Focus and select the editor text. editor.focus().select(); // Cancel the edit if we lose focus or the escape key is pressed. thisevent = editor.on('blur', this.edit_maxmark_cancel, this, activity, false); this.editmaxmarkevents.push(thisevent); thisevent = editor.on('key', this.edit_maxmark_cancel, 'esc', this, activity, true); this.editmaxmarkevents.push(thisevent); // Handle form submission. thisevent = editform.on('submit', this.edit_maxmark_submit, this, activity, oldmaxmark); this.editmaxmarkevents.push(thisevent); }); }, /** * Handles the submit event when editing the activity or resources maxmark. * * @protected * @method edit_maxmark_submit * @param {EventFacade} ev The event that triggered this. * @param {Node} activity The activity whose maxmark we are altering. * @param {String} originalmaxmark The original maxmark the activity or resource had. */ edit_maxmark_submit: function(ev, activity, originalmaxmark) { // We don't actually want to submit anything. ev.preventDefault(); var newmaxmark = Y.Lang.trim(activity.one(SELECTOR.ACTIVITYFORM + ' ' + SELECTOR.ACTIVITYMAXMARK).get('value')); var spinner = this.add_spinner(activity); this.edit_maxmark_clear(activity); activity.one(SELECTOR.INSTANCEMAXMARK).setContent(newmaxmark); if (newmaxmark !== null && newmaxmark !== "" && newmaxmark !== originalmaxmark) { var data = { 'class': 'resource', 'field': 'updatemaxmark', 'maxmark': newmaxmark, 'id': Y.Moodle.mod_quiz.util.slot.getId(activity) }; this.send_request(data, spinner, function(response) { if (response.instancemaxmark) { activity.one(SELECTOR.INSTANCEMAXMARK).setContent(response.instancemaxmark); } }); } }, /** * Handles the cancel event when editing the activity or resources maxmark. * * @protected * @method edit_maxmark_cancel * @param {EventFacade} ev The event that triggered this. * @param {Node} activity The activity whose maxmark we are altering. * @param {Boolean} preventdefault If true we should prevent the default action from occuring. */ edit_maxmark_cancel: function(ev, activity, preventdefault) { if (preventdefault) { ev.preventDefault(); } this.edit_maxmark_clear(activity); }, /** * Handles clearing the editing UI and returning things to the original state they were in. * * @protected * @method edit_maxmark_clear * @param {Node} activity The activity whose maxmark we were altering. */ edit_maxmark_clear: function(activity) { // Detach all listen events to prevent duplicate triggers new Y.EventHandle(this.editmaxmarkevents).detach(); var editform = activity.one(SELECTOR.ACTIVITYFORM), instructions = activity.one('#id_editinstructions'); if (editform) { editform.replace(editform.getData('anchor')); } if (instructions) { instructions.remove(); } // Remove the editing class again to revert the display. activity.removeClass(CSS.EDITINGMAXMARK); // Refocus the link which was clicked originally so the user can continue using keyboard nav. Y.later(100, this, function() { activity.one(SELECTOR.EDITMAXMARK).focus(); }); // TODO MDL-50768 This hack is to keep Behat happy until they release a version of // MinkSelenium2Driver that fixes // https://github.com/Behat/MinkSelenium2Driver/issues/80. if (!Y.one('input[name=maxmark')) { Y.one('body').append(''); } }, /** * Joins or separates the given slot with the page of the previous slot. Reorders the pages of * the other slots * * @protected * @method update_page_break * @param {EventFacade} ev The event that was fired. * @param {Node} button The button that triggered this action. * @param {Node} activity The activity node that this action will be performed on. * @param {String} action The action, addpagebreak or removepagebreak. * @chainable */ update_page_break: function(ev, button, activity, action) { // Prevent the default button action ev.preventDefault(); var nextactivity = activity.next('li.activity.slot'); var spinner = this.add_spinner(nextactivity); var value = action === 'removepagebreak' ? 1 : 2; var data = { 'class': 'resource', 'field': 'updatepagebreak', 'id': Y.Moodle.mod_quiz.util.slot.getId(nextactivity), 'value': value }; this.send_request(data, spinner, function(response) { if (response.slots) { if (action === 'addpagebreak') { Y.Moodle.mod_quiz.util.page.add(activity); } else { var page = activity.next(Y.Moodle.mod_quiz.util.page.SELECTORS.PAGE); Y.Moodle.mod_quiz.util.page.remove(page, true); } this.reorganise_edit_page(); } }); return this; }, /** * Updates a slot to either require the question in the previous slot to * have been answered, or not, * * @protected * @method update_page_break * @param {EventFacade} ev The event that was fired. * @param {Node} button The button that triggered this action. * @param {Node} activity The activity node that this action will be performed on. * @param {String} action The action, adddependency or removedependency. * @chainable */ update_dependency: function(ev, button, activity, action) { // Prevent the default button action. ev.preventDefault(); var spinner = this.add_spinner(activity); var data = { 'class': 'resource', 'field': 'updatedependency', 'id': Y.Moodle.mod_quiz.util.slot.getId(activity), 'value': action === 'adddependency' ? 1 : 0 }; this.send_request(data, spinner, function(response) { if (response.hasOwnProperty('requireprevious')) { Y.Moodle.mod_quiz.util.slot.updateDependencyIcon(activity, response.requireprevious); } }); return this; }, /** * Reorganise the UI after every edit action. * * @protected * @method reorganise_edit_page */ reorganise_edit_page: function() { Y.Moodle.mod_quiz.util.slot.reorderSlots(); Y.Moodle.mod_quiz.util.slot.reorderPageBreaks(); Y.Moodle.mod_quiz.util.page.reorderPages(); Y.Moodle.mod_quiz.util.slot.updateOneSlotSections(); Y.Moodle.mod_quiz.util.slot.updateAllDependencyIcons(); }, NAME: 'mod_quiz-resource-toolbox', ATTRS: { courseid: { 'value': 0 }, quizid: { 'value': 0 } } }); M.mod_quiz.resource_toolbox = null; M.mod_quiz.init_resource_toolbox = function(config) { M.mod_quiz.resource_toolbox = new RESOURCETOOLBOX(config); return M.mod_quiz.resource_toolbox; }; PK9B\;88yui/src/toolboxes/js/section.jsnu[/** * Section toolbox class. * * This class is responsible for managing AJAX interactions with sections * when adding, editing, removing section headings. * * @module moodle-mod_quiz-toolboxes * @namespace M.mod_quiz.toolboxes */ /** * Section toolbox class. * * This class is responsible for managing AJAX interactions with sections * when adding, editing, removing section headings when editing a quiz. * * @class section * @constructor * @extends M.mod_quiz.toolboxes.toolbox */ var SECTIONTOOLBOX = function() { SECTIONTOOLBOX.superclass.constructor.apply(this, arguments); }; Y.extend(SECTIONTOOLBOX, TOOLBOX, { /** * An Array of events added when editing a max mark field. * These should all be detached when editing is complete. * * @property editsectionevents * @protected * @type Array * @protected */ editsectionevents: [], /** * Initialize the section toolboxes module. * * Updates all span.commands with relevant handlers and other required changes. * * @method initializer * @protected */ initializer: function() { M.mod_quiz.quizbase.register_module(this); BODY.delegate('key', this.handle_data_action, 'down:enter', SELECTOR.ACTIVITYACTION, this); Y.delegate('click', this.handle_data_action, BODY, SELECTOR.ACTIVITYACTION, this); Y.delegate('change', this.handle_data_action, BODY, SELECTOR.EDITSHUFFLEQUESTIONSACTION, this); }, /** * Handles the delegation event. When this is fired someone has triggered an action. * * Note not all actions will result in an AJAX enhancement. * * @protected * @method handle_data_action * @param {EventFacade} ev The event that was triggered. * @returns {boolean} */ handle_data_action: function(ev) { // We need to get the anchor element that triggered this event. var node = ev.target; if (!node.test('a') && !node.test('input[data-action]')) { node = node.ancestor(SELECTOR.ACTIVITYACTION); } // From the anchor we can get both the activity (added during initialisation) and the action being // performed (added by the UI as a data attribute). var action = node.getData('action'), activity = node.ancestor(SELECTOR.ACTIVITYLI); if ((!node.test('a') && !node.test('input[data-action]')) || !action || !activity) { // It wasn't a valid action node. return; } // Switch based upon the action and do the desired thing. switch (action) { case 'edit_section_title': // The user wishes to edit the section headings. this.edit_section_title(ev, node, activity, action); break; case 'shuffle_questions': // The user wishes to edit the shuffle questions of the section (resource). this.edit_shuffle_questions(ev, node, activity, action); break; case 'deletesection': // The user is deleting the activity. this.delete_section_with_confirmation(ev, node, activity, action); break; default: // Nothing to do here! break; } }, /** * Deletes the given section heading after confirmation. * * @protected * @method delete_section_with_confirmation * @param {EventFacade} ev The event that was fired. * @param {Node} button The button that triggered this action. * @param {Node} activity The activity node that this action will be performed on. * @chainable */ delete_section_with_confirmation: function(ev, button, activity) { ev.preventDefault(); require(['core/notification'], function(Notification) { Notification.saveCancelPromise( M.util.get_string('confirm', 'moodle'), M.util.get_string('confirmremovesectionheading', 'quiz', activity.getData('sectionname')), M.util.get_string('yes', 'moodle') ).then(function() { var spinner = M.util.add_spinner(Y, activity.one(SELECTOR.ACTIONAREA)); var data = { 'class': 'section', 'action': 'DELETE', 'id': activity.get('id').replace('section-', '') }; this.send_request(data, spinner, function(response) { if (response.deleted) { window.location.reload(true); } }); return; }.bind(this)).catch(function() { // User cancelled. }); }.bind(this)); }, /** * Edit the edit section title for the section * * @protected * @method edit_section_title * @param {EventFacade} ev The event that was fired. * @param {Node} button The button that triggered this action. * @param {Node} activity The activity node that this action will be performed on. * @param {String} action The action that has been requested. * @return Boolean */ edit_section_title: function(ev, button, activity) { // Get the element we're working on var activityid = activity.get('id').replace('section-', ''), instancesection = activity.one(SELECTOR.INSTANCESECTION), thisevent, anchor = instancesection, // Grab the anchor so that we can swap it with the edit form. data = { 'class': 'section', 'field': 'getsectiontitle', 'id': activityid }; // Prevent the default actions. ev.preventDefault(); this.send_request(data, null, function(response) { // Try to retrieve the existing string from the server. var oldtext = response.instancesection; // Create the editor and submit button. var editform = Y.Node.create(''); var editinstructions = Y.Node.create('') .set('innerHTML', M.util.get_string('edittitleinstructions', 'moodle')); var editor = Y.Node.create('').setAttrs({ 'value': oldtext, 'autocomplete': 'off', 'aria-describedby': 'id_editinstructions', 'maxLength': '255' // This is the maxlength in DB. }); // Clear the existing content and put the editor in. editform.appendChild(editor); editform.setData('anchor', anchor); instancesection.insert(editinstructions, 'before'); anchor.replace(editform); // Focus and select the editor text. editor.focus().select(); // Cancel the edit if we lose focus or the escape key is pressed. thisevent = editor.on('blur', this.edit_section_title_cancel, this, activity, false); this.editsectionevents.push(thisevent); thisevent = editor.on('key', this.edit_section_title_cancel, 'esc', this, activity, true); this.editsectionevents.push(thisevent); // Handle form submission. thisevent = editform.on('submit', this.edit_section_title_submit, this, activity, oldtext); this.editsectionevents.push(thisevent); }); }, /** * Handles the submit event when editing section heading. * * @protected * @method edit_section_title_submiy * @param {EventFacade} ev The event that triggered this. * @param {Node} activity The activity whose maxmark we are altering. * @param {String} oldtext The original maxmark the activity or resource had. */ edit_section_title_submit: function(ev, activity, oldtext) { // We don't actually want to submit anything. ev.preventDefault(); var newtext = Y.Lang.trim(activity.one(SELECTOR.SECTIONFORM + ' ' + SELECTOR.SECTIONINPUT).get('value')); var spinner = M.util.add_spinner(Y, activity.one(SELECTOR.INSTANCESECTIONAREA)); this.edit_section_title_clear(activity); if (newtext !== null && newtext !== oldtext) { var instancesection = activity.one(SELECTOR.INSTANCESECTION); var instancesectiontext = newtext; if (newtext.trim() === '') { // Add a visually-hidden default section heading text to make sure we don't end up with an empty section heading. instancesectiontext = M.util.get_string('sectionnoname', 'quiz'); instancesection.addClass('visually-hidden'); } else { // Show the section heading when a non-empty value is set. instancesection.removeClass('visually-hidden'); } instancesection.setContent(instancesectiontext); var data = { 'class': 'section', 'field': 'updatesectiontitle', 'newheading': newtext, 'id': activity.get('id').replace('section-', '') }; this.send_request(data, spinner, function(response) { if (response) { // Set the content of the section heading if for some reason the response is different from the new text. // e.g. filters were applied, the update failed, etc. if (newtext !== response.instancesection) { if (response.instancesection.trim() === '') { // Add a visually-hidden default section heading text. instancesectiontext = M.util.get_string('sectionnoname', 'quiz'); instancesection.addClass('visually-hidden'); } else { instancesectiontext = response.instancesection; // Show the section heading when a non-empty value is set. instancesection.removeClass('visually-hidden'); } instancesection.setContent(instancesectiontext); } activity.one(SELECTOR.EDITSECTIONICON).set('title', M.util.get_string('sectionheadingedit', 'quiz', response.instancesection)); activity.one(SELECTOR.EDITSECTIONICON).set('alt', M.util.get_string('sectionheadingedit', 'quiz', response.instancesection)); var deleteicon = activity.one(SELECTOR.DELETESECTIONICON); if (deleteicon) { deleteicon.set('title', M.util.get_string('sectionheadingremove', 'quiz', response.instancesection)); deleteicon.set('alt', M.util.get_string('sectionheadingremove', 'quiz', response.instancesection)); } } }); } }, /** * Handles the cancel event when editing the activity or resources maxmark. * * @protected * @method edit_maxmark_cancel * @param {EventFacade} ev The event that triggered this. * @param {Node} activity The activity whose maxmark we are altering. * @param {Boolean} preventdefault If true we should prevent the default action from occuring. */ edit_section_title_cancel: function(ev, activity, preventdefault) { if (preventdefault) { ev.preventDefault(); } this.edit_section_title_clear(activity); }, /** * Handles clearing the editing UI and returning things to the original state they were in. * * @protected * @method edit_maxmark_clear * @param {Node} activity The activity whose maxmark we were altering. */ edit_section_title_clear: function(activity) { // Detach all listen events to prevent duplicate triggers new Y.EventHandle(this.editsectionevents).detach(); var editform = activity.one(SELECTOR.SECTIONFORM), instructions = activity.one('#id_editinstructions'); if (editform) { editform.replace(editform.getData('anchor')); } if (instructions) { instructions.remove(); } // Refocus the link which was clicked originally so the user can continue using keyboard nav. Y.later(100, this, function() { activity.one(SELECTOR.EDITSECTION).focus(); }); // This hack is to keep Behat happy until they release a version of // MinkSelenium2Driver that fixes // https://github.com/Behat/MinkSelenium2Driver/issues/80. if (!Y.one('input[name=section]')) { Y.one('body').append(''); } }, /** * Edit the edit shuffle questions for the section * * @protected * @method edit_shuffle_questions * @param {EventFacade} ev The event that was fired. * @param {Node} button The button that triggered this action. * @param {Node} activity The activity node that this action will be performed on. * @return Boolean */ edit_shuffle_questions: function(ev, button, activity) { var newvalue; if (activity.one(SELECTOR.EDITSHUFFLEQUESTIONSACTION).get('checked')) { newvalue = 1; activity.addClass('shuffled'); } else { newvalue = 0; activity.removeClass('shuffled'); } // Prevent the default actions. ev.preventDefault(); // Get the element we're working on var data = { 'class': 'section', 'field': 'updateshufflequestions', 'id': activity.get('id').replace('section-', ''), 'newshuffle': newvalue }; // Send request. var spinner = M.util.add_spinner(Y, activity.one(SELECTOR.EDITSHUFFLEAREA)); this.send_request(data, spinner); } }, { NAME: 'mod_quiz-section-toolbox', ATTRS: { courseid: { 'value': 0 }, quizid: { 'value': 0 } } }); M.mod_quiz.init_section_toolbox = function(config) { return new SECTIONTOOLBOX(config); }; PK9B\VFBByui/src/toolboxes/js/toolbox.jsnu[/* eslint-disable no-unused-vars */ /** * Resource and activity toolbox class. * * This class is responsible for managing AJAX interactions with activities and resources * when viewing a course in editing mode. * * @module moodle-course-toolboxes * @namespace M.course.toolboxes */ // The CSS classes we use. var CSS = { ACTIVITYINSTANCE: 'activityinstance', AVAILABILITYINFODIV: 'div.availabilityinfo', CONTENTWITHOUTLINK: 'contentwithoutlink', CONDITIONALHIDDEN: 'conditionalhidden', DIMCLASS: 'dimmed', DIMMEDTEXT: 'dimmed_text', EDITINSTRUCTIONS: 'editinstructions', EDITINGMAXMARK: 'editor_displayed', HIDE: 'hide', JOIN: 'page_join', MODINDENTCOUNT: 'mod-indent-', MODINDENTHUGE: 'mod-indent-huge', PAGE: 'page', SECTIONHIDDENCLASS: 'hidden', SECTIONIDPREFIX: 'section-', SELECTMULTIPLE: 'select-multiple', SLOT: 'slot', SHOW: 'editing_show', TITLEEDITOR: 'titleeditor' }, // The CSS selectors we use. SELECTOR = { ACTIONAREA: '.actions', ACTIONLINKTEXT: '.actionlinktext', ACTIVITYACTION: 'a.cm-edit-action[data-action], a.editing_maxmark, a.editing_section, input.shuffle_questions', ACTIVITYFORM: 'span.instancemaxmarkcontainer form', ACTIVITYINSTANCE: '.' + CSS.ACTIVITYINSTANCE, SECTIONINSTANCE: '.sectioninstance', ACTIVITYLI: 'li.activity, li.section', ACTIVITYMAXMARK: 'input[name=maxmark]', COMMANDSPAN: '.commands', CONTENTAFTERLINK: 'div.contentafterlink', CONTENTWITHOUTLINK: 'div.contentwithoutlink', DELETESECTIONICON: 'a.editing_delete .icon', EDITMAXMARK: 'a.editing_maxmark', EDITSECTION: 'a.editing_section', EDITSECTIONICON: 'a.editing_section .icon', EDITSHUFFLEQUESTIONSACTION: 'input.cm-edit-action[data-action]', EDITSHUFFLEAREA: '.instanceshufflequestions .shuffle-progress', HIDE: 'a.editing_hide', HIGHLIGHT: 'a.editing_highlight', INSTANCENAME: 'span.instancename', INSTANCEMAXMARK: 'span.instancemaxmark', INSTANCESECTION: 'span.instancesection', INSTANCESECTIONAREA: 'div.section-heading', MODINDENTDIV: '.mod-indent', MODINDENTOUTER: '.mod-indent-outer', NUMQUESTIONS: '.numberofquestions', PAGECONTENT: 'div#page-content', PAGELI: 'li.page', SECTIONLI: 'li.section', SECTIONUL: 'ul.section', SECTIONFORM: '.instancesectioncontainer form', SECTIONINPUT: 'input[name=section]', SELECTMULTIPLEBUTTON: '#selectmultiplecommand', SELECTMULTIPLECANCELBUTTON: '#selectmultiplecancelcommand', SELECTMULTIPLECHECKBOX: '.select-multiple-checkbox', SELECTMULTIPLEDELETEBUTTON: '#selectmultipledeletecommand', SELECTALL: '#questionselectall', SHOW: 'a.' + CSS.SHOW, SLOTLI: 'li.slot', SUMMARKS: '.mod_quiz_summarks' }, BODY = Y.one(document.body); // Setup the basic namespace. M.mod_quiz = M.mod_quiz || {}; /** * The toolbox class is a generic class which should never be directly * instantiated. Please extend it instead. * * @class toolbox * @constructor * @protected * @extends Base */ var TOOLBOX = function() { TOOLBOX.superclass.constructor.apply(this, arguments); }; Y.extend(TOOLBOX, Y.Base, { /** * Send a request using the REST API * * @method send_request * @param {Object} data The data to submit with the AJAX request * @param {Node} [statusspinner] A statusspinner which may contain a section loader * @param {Function} success_callback The callback to use on success * @param {Object} [optionalconfig] Any additional configuration to submit * @chainable */ send_request: function(data, statusspinner, success_callback, optionalconfig) { // Default data structure if (!data) { data = {}; } // Handle any variables which we must pass back through to var pageparams = this.get('config').pageparams, varname; for (varname in pageparams) { data[varname] = pageparams[varname]; } data.sesskey = M.cfg.sesskey; data.courseid = this.get('courseid'); data.quizid = this.get('quizid'); var uri = M.cfg.wwwroot + this.get('ajaxurl'); // Define the configuration to send with the request var responsetext = []; var config = { method: 'POST', data: data, on: { success: function(tid, response) { try { responsetext = Y.JSON.parse(response.responseText); if (responsetext.error) { new M.core.ajaxException(responsetext); } } catch (e) { // Ignore. } // Run the callback if we have one. if (responsetext.hasOwnProperty('newsummarks')) { Y.one(SELECTOR.SUMMARKS).setHTML(responsetext.newsummarks); } if (responsetext.hasOwnProperty('newnumquestions')) { Y.one(SELECTOR.NUMQUESTIONS).setHTML( M.util.get_string('numquestionsx', 'quiz', responsetext.newnumquestions) ); } if (success_callback) { Y.bind(success_callback, this, responsetext)(); } if (statusspinner) { window.setTimeout(function() { statusspinner.hide(); }, 400); } }, failure: function(tid, response) { if (statusspinner) { statusspinner.hide(); } new M.core.ajaxException(response); } }, context: this }; // Apply optional config if (optionalconfig) { for (varname in optionalconfig) { config[varname] = optionalconfig[varname]; } } if (statusspinner) { statusspinner.show(); } // Send the request Y.io(uri, config); return this; } }, { NAME: 'mod_quiz-toolbox', ATTRS: { /** * The ID of the Moodle Course being edited. * * @attribute courseid * @default 0 * @type Number */ courseid: { 'value': 0 }, /** * The Moodle course format. * * @attribute format * @default 'topics' * @type String */ quizid: { 'value': 0 }, /** * The URL to use when submitting requests. * @attribute ajaxurl * @default null * @type String */ ajaxurl: { 'value': null }, /** * Any additional configuration passed when creating the instance. * * @attribute config * @default {} * @type Object */ config: { 'value': {} } } } ); PK9B\2Dnyui/src/modform/build.jsonnu[{ "name": "moodle-mod_quiz-modform", "builds": { "moodle-mod_quiz-modform": { "jsfiles": [ "modform.js" ] } } } PK9B\ gH=!yui/src/modform/meta/modform.jsonnu[{ "moodle-mod_quiz-modform": { "requires": [ "base", "node", "event" ] } } PK9B\nm\ppyui/src/modform/js/modform.jsnu[/** * The modform class has all the JavaScript specific to mod/quiz/mod_form.php. * * @module moodle-mod_quiz-modform */ var MODFORM = function() { MODFORM.superclass.constructor.apply(this, arguments); }; /** * The coursebase class to provide shared functionality to Modules within * Moodle. * * @class M.course.coursebase * @constructor */ Y.extend(MODFORM, Y.Base, { repaginateCheckbox: null, qppSelect: null, qppInitialValue: 0, initializer: function() { this.repaginateCheckbox = Y.one('#id_repaginatenow'); if (!this.repaginateCheckbox) { // The checkbox only appears when editing an existing quiz. return; } this.qppSelect = Y.one('#id_questionsperpage'); this.qppInitialValue = this.qppSelect.get('value'); this.qppSelect.on('change', this.qppChanged, this); }, qppChanged: function() { Y.later(50, this, function() { if (!this.repaginateCheckbox.get('disabled')) { this.repaginateCheckbox.set('checked', this.qppSelect.get('value') !== this.qppInitialValue); } }); } }); // Ensure that M.mod_quiz exists and that coursebase is initialised correctly M.mod_quiz = M.mod_quiz || {}; M.mod_quiz.modform = M.mod_quiz.modform || new MODFORM(); M.mod_quiz.modform.init = function() { return new MODFORM(); }; PK9B\ %hhFyui/build/moodle-mod_quiz-util-base/moodle-mod_quiz-util-base-debug.jsnu[YUI.add('moodle-mod_quiz-util-base', function (Y, NAME) { /** * The Moodle.mod_quiz.util classes provide quiz-related utility functions. * * @module moodle-mod_quiz-util * @main */ Y.namespace('Moodle.mod_quiz.util'); /** * A collection of general utility functions for use in quiz. * * @class Moodle.mod_quiz.util * @static */ }, '@VERSION@'); PK9B\ %hh@yui/build/moodle-mod_quiz-util-base/moodle-mod_quiz-util-base.jsnu[YUI.add('moodle-mod_quiz-util-base', function (Y, NAME) { /** * The Moodle.mod_quiz.util classes provide quiz-related utility functions. * * @module moodle-mod_quiz-util * @main */ Y.namespace('Moodle.mod_quiz.util'); /** * A collection of general utility functions for use in quiz. * * @class Moodle.mod_quiz.util * @static */ }, '@VERSION@'); PK9B\LIddDyui/build/moodle-mod_quiz-util-base/moodle-mod_quiz-util-base-min.jsnu[YUI.add("moodle-mod_quiz-util-base",function(o,d){o.namespace("Moodle.mod_quiz.util")},"@VERSION@");PK9B\4))@yui/build/moodle-mod_quiz-util-page/moodle-mod_quiz-util-page.jsnu[YUI.add('moodle-mod_quiz-util-page', function (Y, NAME) { /* global YUI */ /** * A collection of utility classes for use with pages. * * @module moodle-mod_quiz-util * @submodule moodle-mod_quiz-util-page */ Y.namespace('Moodle.mod_quiz.util.page'); /** * A collection of utility classes for use with pages. * * @class Moodle.mod_quiz.util.page * @static */ Y.Moodle.mod_quiz.util.page = { CSS: { PAGE: 'page' }, CONSTANTS: { ACTIONMENUIDPREFIX: 'action-menu-', ACTIONMENUBARIDSUFFIX: '-menubar', ACTIONMENUMENUIDSUFFIX: '-menu', PAGEIDPREFIX: 'page-', PAGENUMBERPREFIX: M.util.get_string('page', 'moodle') + ' ' }, SELECTORS: { ACTIONMENU: 'div.moodle-actionmenu', ACTIONMENUBAR: '.menubar', ACTIONMENUMENU: '.menu', ADDASECTION: '[data-action="addasection"]', PAGE: 'li.page', INSTANCENAME: '.instancename', NUMBER: 'h4' }, /** * Retrieve the page item from one of it's child Nodes. * * @method getPageFromComponent * @param pagecomponent {Node} The component Node. * @return {Node|null} The Page Node. */ getPageFromComponent: function(pagecomponent) { return Y.one(pagecomponent).ancestor(this.SELECTORS.PAGE, true); }, /** * Retrieve the page item from one of it's previous siblings. * * @method getPageFromSlot * @param pagecomponent {Node} The component Node. * @return {Node|null} The Page Node. */ getPageFromSlot: function(slot) { return Y.one(slot).previous(this.SELECTORS.PAGE); }, /** * Returns the page ID for the provided page. * * @method getId * @param page {Node} The page to find an ID for. * @return {Number|false} The ID of the page in question or false if no ID was found. */ getId: function(page) { // We perform a simple substitution operation to get the ID. var id = page.get('id').replace( this.CONSTANTS.PAGEIDPREFIX, ''); // Attempt to validate the ID. id = parseInt(id, 10); if (typeof id === 'number' && isFinite(id)) { return id; } return false; }, /** * Updates the page id for the provided page. * * @method setId * @param page {Node} The page to update the number for. * @param id int The id value. * @return void */ setId: function(page, id) { page.set('id', this.CONSTANTS.PAGEIDPREFIX + id); }, /** * Determines the page name for the provided page. * * @method getName * @param page {Node} The page to find a name for. * @return {string|false} The name of the page in question or false if no ID was found. */ getName: function(page) { var instance = page.one(this.SELECTORS.INSTANCENAME); if (instance) { return instance.get('firstChild').get('data'); } return null; }, /** * Determines the page number for the provided page. * * @method getNumber * @param page {Node} The page to find a number for. * @return {Number|false} The number of the page in question or false if no number was found. */ getNumber: function(page) { // We perform a simple substitution operation to get the number. var number = page.one(this.SELECTORS.NUMBER).get('text').replace( this.CONSTANTS.PAGENUMBERPREFIX, ''); // Attempt to validate the ID. number = parseInt(number, 10); if (typeof number === 'number' && isFinite(number)) { return number; } return false; }, /** * Updates the page number for the provided page. * * @method setNumber * @param page {Node} The page to update the number for. * @return void */ setNumber: function(page, number) { page.one(this.SELECTORS.NUMBER).set('text', this.CONSTANTS.PAGENUMBERPREFIX + number); }, /** * Returns a list of all page elements. * * @method getPages * @return {node[]} An array containing page nodes. */ getPages: function() { return Y.all(Y.Moodle.mod_quiz.util.slot.SELECTORS.PAGECONTENT + ' ' + Y.Moodle.mod_quiz.util.slot.SELECTORS.SECTIONUL + ' ' + this.SELECTORS.PAGE); }, /** * Is the given element a page element? * * @method isPage * @param page Page node * @return boolean */ isPage: function(page) { if (!page) { return false; } return page.hasClass(this.CSS.PAGE); }, /** * Does the page have atleast one slot? * * @method isEmpty * @param page Page node * @return boolean */ isEmpty: function(page) { var activity = page.next('li.activity'); if (!activity) { return true; } return !activity.hasClass('slot'); }, /** * Add a page and related elements to the list of slots. * * @method add * @param beforenode Int | Node | HTMLElement | String to add * @return page Page node */ add: function(beforenode) { var pagenumber = this.getNumber(this.getPageFromSlot(beforenode)) + 1; var pagehtml = M.mod_quiz.resource_toolbox.get('config').pagehtml; // Normalise the page number. pagehtml = pagehtml.replace(/%%PAGENUMBER%%/g, pagenumber); // Create the page node. var page = Y.Node.create(pagehtml); // Assign is as a drop target. YUI().use('dd-drop', function(Y) { var drop = new Y.DD.Drop({ node: page, groups: M.mod_quiz.dragres.groups }); page.drop = drop; }); // Insert in the correct place. beforenode.insert(page, 'after'); // Enhance the add menu to make if fully visible and clickable. if (typeof M.core.actionmenu !== "undefined") { M.core.actionmenu.newDOMNode(page); } return page; }, /** * Remove a page and related elements from the list of slots. * * @method remove * @param page Page node * @return void */ remove: function(page, keeppagebreak) { // Remove page break from previous slot. var previousslot = page.previous(Y.Moodle.mod_quiz.util.slot.SELECTORS.SLOT); if (!keeppagebreak && previousslot) { Y.Moodle.mod_quiz.util.slot.removePageBreak(previousslot); } page.remove(); }, /** * Reset the order of the numbers given to each page. * * @method reorderPages * @return void */ reorderPages: function() { // Get list of page nodes. var pages = this.getPages(); var currentpagenumber = 0; // Loop through pages incrementing the number each time. pages.each(function(page) { // Is the page empty? if (this.isEmpty(page)) { var keeppagebreak = page.next('li.slot') ? true : false; this.remove(page, keeppagebreak); return; } currentpagenumber++; // Set page number. this.setNumber(page, currentpagenumber); this.setId(page, currentpagenumber); }, this); // Reorder action menus this.reorderActionMenus(); }, /** * Reset the order of the numbers given to each action menu. * * @method reorderActionMenus * @return void */ reorderActionMenus: function() { // Get list of action menu nodes. var actionmenus = this.getActionMenus(); // Loop through pages incrementing the number each time. actionmenus.each(function(actionmenu, key) { var previousActionMenu = actionmenus.item(key - 1), previousActionMenunumber = 0; if (previousActionMenu) { previousActionMenunumber = this.getActionMenuId(previousActionMenu); } var id = previousActionMenunumber + 1; // Set menu id. this.setActionMenuId(actionmenu, id); // Update action-menu-1-menubar var menubar = actionmenu.one(this.SELECTORS.ACTIONMENUBAR); menubar.set('id', this.CONSTANTS.ACTIONMENUIDPREFIX + id + this.CONSTANTS.ACTIONMENUBARIDSUFFIX); // Update action-menu-1-menu var menumenu = actionmenu.one(this.SELECTORS.ACTIONMENUMENU); menumenu.set('id', this.CONSTANTS.ACTIONMENUIDPREFIX + id + this.CONSTANTS.ACTIONMENUMENUIDSUFFIX); // Update the URL of the add-section action. menumenu.one(this.SELECTORS.ADDASECTION).set('href', menumenu.one(this.SELECTORS.ADDASECTION).get('href').replace(/\baddsectionatpage=\d+\b/, 'addsectionatpage=' + id)); }, this); }, /** * Returns a list of all page elements. * * @method getActionMenus * @return {node[]} An array containing page nodes. */ getActionMenus: function() { return Y.all(Y.Moodle.mod_quiz.util.slot.SELECTORS.PAGECONTENT + ' ' + Y.Moodle.mod_quiz.util.slot.SELECTORS.SECTIONUL + ' ' + this.SELECTORS.ACTIONMENU); }, /** * Returns the ID for the provided action menu. * * @method getId * @param actionmenu {Node} The actionmenu to find an ID for. * @return {Number|false} The ID of the actionmenu in question or false if no ID was found. */ getActionMenuId: function(actionmenu) { // We perform a simple substitution operation to get the ID. var id = actionmenu.get('id').replace( this.CONSTANTS.ACTIONMENUIDPREFIX, ''); // Attempt to validate the ID. id = parseInt(id, 10); if (typeof id === 'number' && isFinite(id)) { return id; } return false; }, /** * Updates the page id for the provided page. * * @method setId * @param page {Node} The page to update the number for. * @param id int The id value. * @return void */ setActionMenuId: function(actionmenu, id) { actionmenu.set('id', this.CONSTANTS.ACTIONMENUIDPREFIX + id); } }; }, '@VERSION@', {"requires": ["node", "moodle-mod_quiz-util-base"]}); PK9B\4))Fyui/build/moodle-mod_quiz-util-page/moodle-mod_quiz-util-page-debug.jsnu[YUI.add('moodle-mod_quiz-util-page', function (Y, NAME) { /* global YUI */ /** * A collection of utility classes for use with pages. * * @module moodle-mod_quiz-util * @submodule moodle-mod_quiz-util-page */ Y.namespace('Moodle.mod_quiz.util.page'); /** * A collection of utility classes for use with pages. * * @class Moodle.mod_quiz.util.page * @static */ Y.Moodle.mod_quiz.util.page = { CSS: { PAGE: 'page' }, CONSTANTS: { ACTIONMENUIDPREFIX: 'action-menu-', ACTIONMENUBARIDSUFFIX: '-menubar', ACTIONMENUMENUIDSUFFIX: '-menu', PAGEIDPREFIX: 'page-', PAGENUMBERPREFIX: M.util.get_string('page', 'moodle') + ' ' }, SELECTORS: { ACTIONMENU: 'div.moodle-actionmenu', ACTIONMENUBAR: '.menubar', ACTIONMENUMENU: '.menu', ADDASECTION: '[data-action="addasection"]', PAGE: 'li.page', INSTANCENAME: '.instancename', NUMBER: 'h4' }, /** * Retrieve the page item from one of it's child Nodes. * * @method getPageFromComponent * @param pagecomponent {Node} The component Node. * @return {Node|null} The Page Node. */ getPageFromComponent: function(pagecomponent) { return Y.one(pagecomponent).ancestor(this.SELECTORS.PAGE, true); }, /** * Retrieve the page item from one of it's previous siblings. * * @method getPageFromSlot * @param pagecomponent {Node} The component Node. * @return {Node|null} The Page Node. */ getPageFromSlot: function(slot) { return Y.one(slot).previous(this.SELECTORS.PAGE); }, /** * Returns the page ID for the provided page. * * @method getId * @param page {Node} The page to find an ID for. * @return {Number|false} The ID of the page in question or false if no ID was found. */ getId: function(page) { // We perform a simple substitution operation to get the ID. var id = page.get('id').replace( this.CONSTANTS.PAGEIDPREFIX, ''); // Attempt to validate the ID. id = parseInt(id, 10); if (typeof id === 'number' && isFinite(id)) { return id; } return false; }, /** * Updates the page id for the provided page. * * @method setId * @param page {Node} The page to update the number for. * @param id int The id value. * @return void */ setId: function(page, id) { page.set('id', this.CONSTANTS.PAGEIDPREFIX + id); }, /** * Determines the page name for the provided page. * * @method getName * @param page {Node} The page to find a name for. * @return {string|false} The name of the page in question or false if no ID was found. */ getName: function(page) { var instance = page.one(this.SELECTORS.INSTANCENAME); if (instance) { return instance.get('firstChild').get('data'); } return null; }, /** * Determines the page number for the provided page. * * @method getNumber * @param page {Node} The page to find a number for. * @return {Number|false} The number of the page in question or false if no number was found. */ getNumber: function(page) { // We perform a simple substitution operation to get the number. var number = page.one(this.SELECTORS.NUMBER).get('text').replace( this.CONSTANTS.PAGENUMBERPREFIX, ''); // Attempt to validate the ID. number = parseInt(number, 10); if (typeof number === 'number' && isFinite(number)) { return number; } return false; }, /** * Updates the page number for the provided page. * * @method setNumber * @param page {Node} The page to update the number for. * @return void */ setNumber: function(page, number) { page.one(this.SELECTORS.NUMBER).set('text', this.CONSTANTS.PAGENUMBERPREFIX + number); }, /** * Returns a list of all page elements. * * @method getPages * @return {node[]} An array containing page nodes. */ getPages: function() { return Y.all(Y.Moodle.mod_quiz.util.slot.SELECTORS.PAGECONTENT + ' ' + Y.Moodle.mod_quiz.util.slot.SELECTORS.SECTIONUL + ' ' + this.SELECTORS.PAGE); }, /** * Is the given element a page element? * * @method isPage * @param page Page node * @return boolean */ isPage: function(page) { if (!page) { return false; } return page.hasClass(this.CSS.PAGE); }, /** * Does the page have atleast one slot? * * @method isEmpty * @param page Page node * @return boolean */ isEmpty: function(page) { var activity = page.next('li.activity'); if (!activity) { return true; } return !activity.hasClass('slot'); }, /** * Add a page and related elements to the list of slots. * * @method add * @param beforenode Int | Node | HTMLElement | String to add * @return page Page node */ add: function(beforenode) { var pagenumber = this.getNumber(this.getPageFromSlot(beforenode)) + 1; var pagehtml = M.mod_quiz.resource_toolbox.get('config').pagehtml; // Normalise the page number. pagehtml = pagehtml.replace(/%%PAGENUMBER%%/g, pagenumber); // Create the page node. var page = Y.Node.create(pagehtml); // Assign is as a drop target. YUI().use('dd-drop', function(Y) { var drop = new Y.DD.Drop({ node: page, groups: M.mod_quiz.dragres.groups }); page.drop = drop; }); // Insert in the correct place. beforenode.insert(page, 'after'); // Enhance the add menu to make if fully visible and clickable. if (typeof M.core.actionmenu !== "undefined") { M.core.actionmenu.newDOMNode(page); } return page; }, /** * Remove a page and related elements from the list of slots. * * @method remove * @param page Page node * @return void */ remove: function(page, keeppagebreak) { // Remove page break from previous slot. var previousslot = page.previous(Y.Moodle.mod_quiz.util.slot.SELECTORS.SLOT); if (!keeppagebreak && previousslot) { Y.Moodle.mod_quiz.util.slot.removePageBreak(previousslot); } page.remove(); }, /** * Reset the order of the numbers given to each page. * * @method reorderPages * @return void */ reorderPages: function() { // Get list of page nodes. var pages = this.getPages(); var currentpagenumber = 0; // Loop through pages incrementing the number each time. pages.each(function(page) { // Is the page empty? if (this.isEmpty(page)) { var keeppagebreak = page.next('li.slot') ? true : false; this.remove(page, keeppagebreak); return; } currentpagenumber++; // Set page number. this.setNumber(page, currentpagenumber); this.setId(page, currentpagenumber); }, this); // Reorder action menus this.reorderActionMenus(); }, /** * Reset the order of the numbers given to each action menu. * * @method reorderActionMenus * @return void */ reorderActionMenus: function() { // Get list of action menu nodes. var actionmenus = this.getActionMenus(); // Loop through pages incrementing the number each time. actionmenus.each(function(actionmenu, key) { var previousActionMenu = actionmenus.item(key - 1), previousActionMenunumber = 0; if (previousActionMenu) { previousActionMenunumber = this.getActionMenuId(previousActionMenu); } var id = previousActionMenunumber + 1; // Set menu id. this.setActionMenuId(actionmenu, id); // Update action-menu-1-menubar var menubar = actionmenu.one(this.SELECTORS.ACTIONMENUBAR); menubar.set('id', this.CONSTANTS.ACTIONMENUIDPREFIX + id + this.CONSTANTS.ACTIONMENUBARIDSUFFIX); // Update action-menu-1-menu var menumenu = actionmenu.one(this.SELECTORS.ACTIONMENUMENU); menumenu.set('id', this.CONSTANTS.ACTIONMENUIDPREFIX + id + this.CONSTANTS.ACTIONMENUMENUIDSUFFIX); // Update the URL of the add-section action. menumenu.one(this.SELECTORS.ADDASECTION).set('href', menumenu.one(this.SELECTORS.ADDASECTION).get('href').replace(/\baddsectionatpage=\d+\b/, 'addsectionatpage=' + id)); }, this); }, /** * Returns a list of all page elements. * * @method getActionMenus * @return {node[]} An array containing page nodes. */ getActionMenus: function() { return Y.all(Y.Moodle.mod_quiz.util.slot.SELECTORS.PAGECONTENT + ' ' + Y.Moodle.mod_quiz.util.slot.SELECTORS.SECTIONUL + ' ' + this.SELECTORS.ACTIONMENU); }, /** * Returns the ID for the provided action menu. * * @method getId * @param actionmenu {Node} The actionmenu to find an ID for. * @return {Number|false} The ID of the actionmenu in question or false if no ID was found. */ getActionMenuId: function(actionmenu) { // We perform a simple substitution operation to get the ID. var id = actionmenu.get('id').replace( this.CONSTANTS.ACTIONMENUIDPREFIX, ''); // Attempt to validate the ID. id = parseInt(id, 10); if (typeof id === 'number' && isFinite(id)) { return id; } return false; }, /** * Updates the page id for the provided page. * * @method setId * @param page {Node} The page to update the number for. * @param id int The id value. * @return void */ setActionMenuId: function(actionmenu, id) { actionmenu.set('id', this.CONSTANTS.ACTIONMENUIDPREFIX + id); } }; }, '@VERSION@', {"requires": ["node", "moodle-mod_quiz-util-base"]}); PK9B\[I Dyui/build/moodle-mod_quiz-util-page/moodle-mod_quiz-util-page-min.jsnu[YUI.add("moodle-mod_quiz-util-page",function(n,e){n.namespace("Moodle.mod_quiz.util.page"),n.Moodle.mod_quiz.util.page={CSS:{PAGE:"page"},CONSTANTS:{ACTIONMENUIDPREFIX:"action-menu-",ACTIONMENUBARIDSUFFIX:"-menubar",ACTIONMENUMENUIDSUFFIX:"-menu",PAGEIDPREFIX:"page-",PAGENUMBERPREFIX:M.util.get_string("page","moodle")+" "},SELECTORS:{ACTIONMENU:"div.moodle-actionmenu",ACTIONMENUBAR:".menubar",ACTIONMENUMENU:".menu",ADDASECTION:'[data-action="addasection"]',PAGE:"li.page",INSTANCENAME:".instancename",NUMBER:"h4"},getPageFromComponent:function(e){return n.one(e).ancestor(this.SELECTORS.PAGE,!0)},getPageFromSlot:function(e){return n.one(e).previous(this.SELECTORS.PAGE)},getId:function(e){e=e.get("id").replace(this.CONSTANTS.PAGEIDPREFIX,"");return!("number"!=typeof(e=parseInt(e,10))||!isFinite(e))&&e},setId:function(e,t){e.set("id",this.CONSTANTS.PAGEIDPREFIX+t)},getName:function(e){e=e.one(this.SELECTORS.INSTANCENAME);return e?e.get("firstChild").get("data"):null},getNumber:function(e){e=e.one(this.SELECTORS.NUMBER).get("text").replace(this.CONSTANTS.PAGENUMBERPREFIX,"");return!("number"!=typeof(e=parseInt(e,10))||!isFinite(e))&&e},setNumber:function(e,t){e.one(this.SELECTORS.NUMBER).set("text",this.CONSTANTS.PAGENUMBERPREFIX+t)},getPages:function(){return n.all(n.Moodle.mod_quiz.util.slot.SELECTORS.PAGECONTENT+" "+n.Moodle.mod_quiz.util.slot.SELECTORS.SECTIONUL+" "+this.SELECTORS.PAGE)},isPage:function(e){return!!e&&e.hasClass(this.CSS.PAGE)},isEmpty:function(e){e=e.next("li.activity");return!e||!e.hasClass("slot")},add:function(e){var t=this.getNumber(this.getPageFromSlot(e))+1,i=M.mod_quiz.resource_toolbox.get("config").pagehtml,i=i.replace(/%%PAGENUMBER%%/g,t),o=n.Node.create(i);return YUI().use("dd-drop",function(e){e=new e.DD.Drop({node:o,groups:M.mod_quiz.dragres.groups});o.drop=e}),e.insert(o,"after"),"undefined"!=typeof M.core.actionmenu&&M.core.actionmenu.newDOMNode(o),o},remove:function(e,t){var i=e.previous(n.Moodle.mod_quiz.util.slot.SELECTORS.SLOT);!t&&i&&n.Moodle.mod_quiz.util.slot.removePageBreak(i),e.remove()},reorderPages:function(){var e=this.getPages(),i=0;e.each(function(e){var t;if(this.isEmpty(e))return t=!!e.next("li.slot"),void this.remove(e,t);i++,this.setNumber(e,i),this.setId(e,i)},this),this.reorderActionMenus()},reorderActionMenus:function(){var o=this.getActionMenus();o.each(function(e,t){var t=o.item(t-1),i=0;t&&(i=this.getActionMenuId(t)),this.setActionMenuId(e,t=i+1),e.one(this.SELECTORS.ACTIONMENUBAR).set("id",this.CONSTANTS.ACTIONMENUIDPREFIX+t+this.CONSTANTS.ACTIONMENUBARIDSUFFIX),(i=e.one(this.SELECTORS.ACTIONMENUMENU)).set("id",this.CONSTANTS.ACTIONMENUIDPREFIX+t+this.CONSTANTS.ACTIONMENUMENUIDSUFFIX),i.one(this.SELECTORS.ADDASECTION).set("href",i.one(this.SELECTORS.ADDASECTION).get("href").replace(/\baddsectionatpage=\d+\b/,"addsectionatpage="+t))},this)},getActionMenus:function(){return n.all(n.Moodle.mod_quiz.util.slot.SELECTORS.PAGECONTENT+" "+n.Moodle.mod_quiz.util.slot.SELECTORS.SECTIONUL+" "+this.SELECTORS.ACTIONMENU)},getActionMenuId:function(e){e=e.get("id").replace(this.CONSTANTS.ACTIONMENUIDPREFIX,"");return!("number"!=typeof(e=parseInt(e,10))||!isFinite(e))&&e},setActionMenuId:function(e,t){e.set("id",this.CONSTANTS.ACTIONMENUIDPREFIX+t)}}},"@VERSION@",{requires:["node","moodle-mod_quiz-util-base"]});PK9B\퀬@yui/build/moodle-mod_quiz-toolboxes/moodle-mod_quiz-toolboxes.jsnu[YUI.add('moodle-mod_quiz-toolboxes', function (Y, NAME) { /* eslint-disable no-unused-vars */ /** * Resource and activity toolbox class. * * This class is responsible for managing AJAX interactions with activities and resources * when viewing a course in editing mode. * * @module moodle-course-toolboxes * @namespace M.course.toolboxes */ // The CSS classes we use. var CSS = { ACTIVITYINSTANCE: 'activityinstance', AVAILABILITYINFODIV: 'div.availabilityinfo', CONTENTWITHOUTLINK: 'contentwithoutlink', CONDITIONALHIDDEN: 'conditionalhidden', DIMCLASS: 'dimmed', DIMMEDTEXT: 'dimmed_text', EDITINSTRUCTIONS: 'editinstructions', EDITINGMAXMARK: 'editor_displayed', HIDE: 'hide', JOIN: 'page_join', MODINDENTCOUNT: 'mod-indent-', MODINDENTHUGE: 'mod-indent-huge', PAGE: 'page', SECTIONHIDDENCLASS: 'hidden', SECTIONIDPREFIX: 'section-', SELECTMULTIPLE: 'select-multiple', SLOT: 'slot', SHOW: 'editing_show', TITLEEDITOR: 'titleeditor' }, // The CSS selectors we use. SELECTOR = { ACTIONAREA: '.actions', ACTIONLINKTEXT: '.actionlinktext', ACTIVITYACTION: 'a.cm-edit-action[data-action], a.editing_maxmark, a.editing_section, input.shuffle_questions', ACTIVITYFORM: 'span.instancemaxmarkcontainer form', ACTIVITYINSTANCE: '.' + CSS.ACTIVITYINSTANCE, SECTIONINSTANCE: '.sectioninstance', ACTIVITYLI: 'li.activity, li.section', ACTIVITYMAXMARK: 'input[name=maxmark]', COMMANDSPAN: '.commands', CONTENTAFTERLINK: 'div.contentafterlink', CONTENTWITHOUTLINK: 'div.contentwithoutlink', DELETESECTIONICON: 'a.editing_delete .icon', EDITMAXMARK: 'a.editing_maxmark', EDITSECTION: 'a.editing_section', EDITSECTIONICON: 'a.editing_section .icon', EDITSHUFFLEQUESTIONSACTION: 'input.cm-edit-action[data-action]', EDITSHUFFLEAREA: '.instanceshufflequestions .shuffle-progress', HIDE: 'a.editing_hide', HIGHLIGHT: 'a.editing_highlight', INSTANCENAME: 'span.instancename', INSTANCEMAXMARK: 'span.instancemaxmark', INSTANCESECTION: 'span.instancesection', INSTANCESECTIONAREA: 'div.section-heading', MODINDENTDIV: '.mod-indent', MODINDENTOUTER: '.mod-indent-outer', NUMQUESTIONS: '.numberofquestions', PAGECONTENT: 'div#page-content', PAGELI: 'li.page', SECTIONLI: 'li.section', SECTIONUL: 'ul.section', SECTIONFORM: '.instancesectioncontainer form', SECTIONINPUT: 'input[name=section]', SELECTMULTIPLEBUTTON: '#selectmultiplecommand', SELECTMULTIPLECANCELBUTTON: '#selectmultiplecancelcommand', SELECTMULTIPLECHECKBOX: '.select-multiple-checkbox', SELECTMULTIPLEDELETEBUTTON: '#selectmultipledeletecommand', SELECTALL: '#questionselectall', SHOW: 'a.' + CSS.SHOW, SLOTLI: 'li.slot', SUMMARKS: '.mod_quiz_summarks' }, BODY = Y.one(document.body); // Setup the basic namespace. M.mod_quiz = M.mod_quiz || {}; /** * The toolbox class is a generic class which should never be directly * instantiated. Please extend it instead. * * @class toolbox * @constructor * @protected * @extends Base */ var TOOLBOX = function() { TOOLBOX.superclass.constructor.apply(this, arguments); }; Y.extend(TOOLBOX, Y.Base, { /** * Send a request using the REST API * * @method send_request * @param {Object} data The data to submit with the AJAX request * @param {Node} [statusspinner] A statusspinner which may contain a section loader * @param {Function} success_callback The callback to use on success * @param {Object} [optionalconfig] Any additional configuration to submit * @chainable */ send_request: function(data, statusspinner, success_callback, optionalconfig) { // Default data structure if (!data) { data = {}; } // Handle any variables which we must pass back through to var pageparams = this.get('config').pageparams, varname; for (varname in pageparams) { data[varname] = pageparams[varname]; } data.sesskey = M.cfg.sesskey; data.courseid = this.get('courseid'); data.quizid = this.get('quizid'); var uri = M.cfg.wwwroot + this.get('ajaxurl'); // Define the configuration to send with the request var responsetext = []; var config = { method: 'POST', data: data, on: { success: function(tid, response) { try { responsetext = Y.JSON.parse(response.responseText); if (responsetext.error) { new M.core.ajaxException(responsetext); } } catch (e) { // Ignore. } // Run the callback if we have one. if (responsetext.hasOwnProperty('newsummarks')) { Y.one(SELECTOR.SUMMARKS).setHTML(responsetext.newsummarks); } if (responsetext.hasOwnProperty('newnumquestions')) { Y.one(SELECTOR.NUMQUESTIONS).setHTML( M.util.get_string('numquestionsx', 'quiz', responsetext.newnumquestions) ); } if (success_callback) { Y.bind(success_callback, this, responsetext)(); } if (statusspinner) { window.setTimeout(function() { statusspinner.hide(); }, 400); } }, failure: function(tid, response) { if (statusspinner) { statusspinner.hide(); } new M.core.ajaxException(response); } }, context: this }; // Apply optional config if (optionalconfig) { for (varname in optionalconfig) { config[varname] = optionalconfig[varname]; } } if (statusspinner) { statusspinner.show(); } // Send the request Y.io(uri, config); return this; } }, { NAME: 'mod_quiz-toolbox', ATTRS: { /** * The ID of the Moodle Course being edited. * * @attribute courseid * @default 0 * @type Number */ courseid: { 'value': 0 }, /** * The Moodle course format. * * @attribute format * @default 'topics' * @type String */ quizid: { 'value': 0 }, /** * The URL to use when submitting requests. * @attribute ajaxurl * @default null * @type String */ ajaxurl: { 'value': null }, /** * Any additional configuration passed when creating the instance. * * @attribute config * @default {} * @type Object */ config: { 'value': {} } } } ); /** * Resource and activity toolbox class. * * This class is responsible for managing AJAX interactions with activities and resources * when viewing a quiz in editing mode. * * @module mod_quiz-resource-toolbox * @namespace M.mod_quiz.resource_toolbox */ /** * Resource and activity toolbox class. * * This is a class extending TOOLBOX containing code specific to resources * * This class is responsible for managing AJAX interactions with activities and resources * when viewing a quiz in editing mode. * * @class resources * @constructor * @extends M.course.toolboxes.toolbox */ var RESOURCETOOLBOX = function() { RESOURCETOOLBOX.superclass.constructor.apply(this, arguments); }; Y.extend(RESOURCETOOLBOX, TOOLBOX, { /** * An Array of events added when editing a max mark field. * These should all be detached when editing is complete. * * @property editmaxmarkevents * @protected * @type Array * @protected */ editmaxmarkevents: [], /** * */ NODE_PAGE: 1, NODE_SLOT: 2, NODE_JOIN: 3, /** * Initialize the resource toolbox * * For each activity the commands are updated and a reference to the activity is attached. * This way it doesn't matter where the commands are going to called from they have a reference to the * activity that they relate to. * This is essential as some of the actions are displayed in an actionmenu which removes them from the * page flow. * * This function also creates a single event delegate to manage all AJAX actions for all activities on * the page. * * @method initializer * @protected */ initializer: function() { M.mod_quiz.quizbase.register_module(this); Y.delegate('click', this.handle_data_action, BODY, SELECTOR.ACTIVITYACTION, this); Y.delegate('click', this.handle_data_action, BODY, SELECTOR.DEPENDENCY_LINK, this); this.initialise_select_multiple(); }, /** * Initialize the select multiple options * * Add actions to the buttons that enable multiple slots to be selected and managed at once. * * @method initialise_select_multiple * @protected */ initialise_select_multiple: function() { // Click select multiple button to show the select all options. Y.one(SELECTOR.SELECTMULTIPLEBUTTON).on('click', function(e) { e.preventDefault(); Y.one('body').addClass(CSS.SELECTMULTIPLE); }); // Click cancel button to show the select all options. Y.one(SELECTOR.SELECTMULTIPLECANCELBUTTON).on('click', function(e) { e.preventDefault(); Y.one('body').removeClass(CSS.SELECTMULTIPLE); }); // Assign the delete method to the delete multiple button. Y.delegate('click', this.delete_multiple_action, BODY, SELECTOR.SELECTMULTIPLEDELETEBUTTON, this); }, /** * Handles the delegation event. When this is fired someone has triggered an action. * * Note not all actions will result in an AJAX enhancement. * * @protected * @method handle_data_action * @param {EventFacade} ev The event that was triggered. * @returns {boolean} */ handle_data_action: function(ev) { // We need to get the anchor element that triggered this event. var node = ev.target; if (!node.test('a')) { node = node.ancestor(SELECTOR.ACTIVITYACTION); } // From the anchor we can get both the activity (added during initialisation) and the action being // performed (added by the UI as a data attribute). var action = node.getData('action'), activity = node.ancestor(SELECTOR.ACTIVITYLI); if (!node.test('a') || !action || !activity) { // It wasn't a valid action node. return; } // Switch based upon the action and do the desired thing. switch (action) { case 'editmaxmark': // The user wishes to edit the maxmark of the resource. this.edit_maxmark(ev, node, activity, action); break; case 'delete': // The user is deleting the activity. this.delete_with_confirmation(ev, node, activity, action); break; case 'addpagebreak': case 'removepagebreak': // The user is adding or removing a page break. this.update_page_break(ev, node, activity, action); break; case 'adddependency': case 'removedependency': // The user is adding or removing a dependency between questions. this.update_dependency(ev, node, activity, action); break; default: // Nothing to do here! break; } }, /** * Add a loading icon to the specified activity. * * The icon is added within the action area. * * @method add_spinner * @param {Node} activity The activity to add a loading icon to * @return {Node|null} The newly created icon, or null if the action area was not found. */ add_spinner: function(activity) { var actionarea = activity.one(SELECTOR.ACTIONAREA); if (actionarea) { return M.util.add_spinner(Y, actionarea); } return null; }, /** * Deletes the given activity or resource after confirmation. * * @protected * @method delete_with_confirmation * @param {EventFacade} ev The event that was fired. * @param {Node} button The button that triggered this action. * @param {Node} activity The activity node that this action will be performed on. */ delete_with_confirmation: function(ev, button, activity) { // Prevent the default button action. ev.preventDefault(); // Get the element we're working on. var element = activity; // Create confirm string (different if element has or does not have name) var qtypename = M.util.get_string( 'pluginname', 'qtype_' + element.getAttribute('class').match(/qtype_([^\s]*)/)[1] ); // Create the confirmation dialogue. require(['core/notification'], function(Notification) { Notification.saveCancelPromise( M.util.get_string('confirm', 'moodle'), M.util.get_string('confirmremovequestion', 'quiz', qtypename), M.util.get_string('yes', 'moodle') ).then(function() { var spinner = this.add_spinner(element); var data = { 'class': 'resource', 'action': 'DELETE', 'id': Y.Moodle.mod_quiz.util.slot.getId(element) }; this.send_request(data, spinner, function(response) { if (response.deleted) { // Actually remove the element. Y.Moodle.mod_quiz.util.slot.remove(element); this.reorganise_edit_page(); if (M.core.actionmenu && M.core.actionmenu.instance) { M.core.actionmenu.instance.hideMenu(ev); } } }); return; }.bind(this)).catch(function() { // User cancelled. }); }.bind(this)); }, /** * Finds the section that would become empty if we remove the selected slots. * * @protected * @method find_sections_that_would_become_empty * @returns {String} The name of the first section found */ find_sections_that_would_become_empty: function() { var section; var sectionnodes = Y.all(SELECTOR.SECTIONLI); if (sectionnodes.size() > 1) { sectionnodes.some(function(node) { var sectionname = node.one(SELECTOR.INSTANCESECTION).getContent(); var checked = node.all(SELECTOR.SELECTMULTIPLECHECKBOX + ':checked'); var unchecked = node.all(SELECTOR.SELECTMULTIPLECHECKBOX + ':not(:checked)'); if (!checked.isEmpty() && unchecked.isEmpty()) { section = sectionname; } return section; }); } return section; }, /** * Takes care of what needs to happen when the user clicks on the delete multiple button. * * @protected * @method delete_multiple_action * @param {EventFacade} ev The event that was fired. */ delete_multiple_action: function(ev) { var problemsection = this.find_sections_that_would_become_empty(); if (typeof problemsection !== 'undefined') { require(['core/notification'], function(Notification) { Notification.alert( M.util.get_string('cannotremoveslots', 'quiz'), M.util.get_string('cannotremoveallsectionslots', 'quiz', problemsection) ); }); } else { this.delete_multiple_with_confirmation(ev); } }, /** * Deletes the given activities or resources after confirmation. * * @protected * @method delete_multiple_with_confirmation * @param {EventFacade} ev The event that was fired. */ delete_multiple_with_confirmation: function(ev) { ev.preventDefault(); var ids = ''; var slots = []; Y.all(SELECTOR.SELECTMULTIPLECHECKBOX + ':checked').each(function(node) { var slot = Y.Moodle.mod_quiz.util.slot.getSlotFromComponent(node); ids += ids === '' ? '' : ','; ids += Y.Moodle.mod_quiz.util.slot.getId(slot); slots.push(slot); }); var element = Y.one('div.mod-quiz-edit-content'); // Do nothing if no slots are selected. if (!slots || !slots.length) { return; } require(['core/notification'], function(Notification) { Notification.saveCancelPromise( M.util.get_string('confirm', 'moodle'), M.util.get_string('areyousureremoveselected', 'quiz'), M.util.get_string('yes', 'moodle') ).then(function() { var spinner = this.add_spinner(element); var data = { 'class': 'resource', field: 'deletemultiple', ids: ids }; // Delete items on server. this.send_request(data, spinner, function(response) { // Delete locally if deleted on server. if (response.deleted) { // Actually remove the element. Y.all(SELECTOR.SELECTMULTIPLECHECKBOX + ':checked').each(function(node) { Y.Moodle.mod_quiz.util.slot.remove(node.ancestor('li.activity')); }); // Update the page numbers and sections. this.reorganise_edit_page(); // Remove the select multiple options. Y.one('body').removeClass(CSS.SELECTMULTIPLE); } }); return; }.bind(this)).catch(function() { // User cancelled. }); }.bind(this)); }, /** * Edit the maxmark for the resource * * @protected * @method edit_maxmark * @param {EventFacade} ev The event that was fired. * @param {Node} button The button that triggered this action. * @param {Node} activity The activity node that this action will be performed on. * @param {String} action The action that has been requested. * @return Boolean */ edit_maxmark: function(ev, button, activity) { // Get the element we're working on var instancemaxmark = activity.one(SELECTOR.INSTANCEMAXMARK), instance = activity.one(SELECTOR.ACTIVITYINSTANCE), currentmaxmark = instancemaxmark.get('firstChild'), oldmaxmark = currentmaxmark.get('data'), maxmarktext = oldmaxmark, thisevent, anchor = instancemaxmark, // Grab the anchor so that we can swap it with the edit form. data = { 'class': 'resource', 'field': 'getmaxmark', 'id': Y.Moodle.mod_quiz.util.slot.getId(activity) }; // Prevent the default actions. ev.preventDefault(); this.send_request(data, null, function(response) { if (M.core.actionmenu && M.core.actionmenu.instance) { M.core.actionmenu.instance.hideMenu(ev); } // Try to retrieve the existing string from the server. if (response.instancemaxmark) { maxmarktext = response.instancemaxmark; } // Create the editor and submit button. var editform = Y.Node.create(''); var editinstructions = Y.Node.create('') .set('innerHTML', M.util.get_string('edittitleinstructions', 'moodle')); var editor = Y.Node.create('').setAttrs({ 'value': maxmarktext, 'autocomplete': 'off', 'aria-describedby': 'id_editinstructions', 'maxLength': '12', 'size': parseInt(this.get('config').questiondecimalpoints, 10) + 2 }); // Clear the existing content and put the editor in. editform.appendChild(editor); editform.setData('anchor', anchor); instance.insert(editinstructions, 'before'); anchor.replace(editform); // We hide various components whilst editing: activity.addClass(CSS.EDITINGMAXMARK); // Focus and select the editor text. editor.focus().select(); // Cancel the edit if we lose focus or the escape key is pressed. thisevent = editor.on('blur', this.edit_maxmark_cancel, this, activity, false); this.editmaxmarkevents.push(thisevent); thisevent = editor.on('key', this.edit_maxmark_cancel, 'esc', this, activity, true); this.editmaxmarkevents.push(thisevent); // Handle form submission. thisevent = editform.on('submit', this.edit_maxmark_submit, this, activity, oldmaxmark); this.editmaxmarkevents.push(thisevent); }); }, /** * Handles the submit event when editing the activity or resources maxmark. * * @protected * @method edit_maxmark_submit * @param {EventFacade} ev The event that triggered this. * @param {Node} activity The activity whose maxmark we are altering. * @param {String} originalmaxmark The original maxmark the activity or resource had. */ edit_maxmark_submit: function(ev, activity, originalmaxmark) { // We don't actually want to submit anything. ev.preventDefault(); var newmaxmark = Y.Lang.trim(activity.one(SELECTOR.ACTIVITYFORM + ' ' + SELECTOR.ACTIVITYMAXMARK).get('value')); var spinner = this.add_spinner(activity); this.edit_maxmark_clear(activity); activity.one(SELECTOR.INSTANCEMAXMARK).setContent(newmaxmark); if (newmaxmark !== null && newmaxmark !== "" && newmaxmark !== originalmaxmark) { var data = { 'class': 'resource', 'field': 'updatemaxmark', 'maxmark': newmaxmark, 'id': Y.Moodle.mod_quiz.util.slot.getId(activity) }; this.send_request(data, spinner, function(response) { if (response.instancemaxmark) { activity.one(SELECTOR.INSTANCEMAXMARK).setContent(response.instancemaxmark); } }); } }, /** * Handles the cancel event when editing the activity or resources maxmark. * * @protected * @method edit_maxmark_cancel * @param {EventFacade} ev The event that triggered this. * @param {Node} activity The activity whose maxmark we are altering. * @param {Boolean} preventdefault If true we should prevent the default action from occuring. */ edit_maxmark_cancel: function(ev, activity, preventdefault) { if (preventdefault) { ev.preventDefault(); } this.edit_maxmark_clear(activity); }, /** * Handles clearing the editing UI and returning things to the original state they were in. * * @protected * @method edit_maxmark_clear * @param {Node} activity The activity whose maxmark we were altering. */ edit_maxmark_clear: function(activity) { // Detach all listen events to prevent duplicate triggers new Y.EventHandle(this.editmaxmarkevents).detach(); var editform = activity.one(SELECTOR.ACTIVITYFORM), instructions = activity.one('#id_editinstructions'); if (editform) { editform.replace(editform.getData('anchor')); } if (instructions) { instructions.remove(); } // Remove the editing class again to revert the display. activity.removeClass(CSS.EDITINGMAXMARK); // Refocus the link which was clicked originally so the user can continue using keyboard nav. Y.later(100, this, function() { activity.one(SELECTOR.EDITMAXMARK).focus(); }); // TODO MDL-50768 This hack is to keep Behat happy until they release a version of // MinkSelenium2Driver that fixes // https://github.com/Behat/MinkSelenium2Driver/issues/80. if (!Y.one('input[name=maxmark')) { Y.one('body').append(''); } }, /** * Joins or separates the given slot with the page of the previous slot. Reorders the pages of * the other slots * * @protected * @method update_page_break * @param {EventFacade} ev The event that was fired. * @param {Node} button The button that triggered this action. * @param {Node} activity The activity node that this action will be performed on. * @param {String} action The action, addpagebreak or removepagebreak. * @chainable */ update_page_break: function(ev, button, activity, action) { // Prevent the default button action ev.preventDefault(); var nextactivity = activity.next('li.activity.slot'); var spinner = this.add_spinner(nextactivity); var value = action === 'removepagebreak' ? 1 : 2; var data = { 'class': 'resource', 'field': 'updatepagebreak', 'id': Y.Moodle.mod_quiz.util.slot.getId(nextactivity), 'value': value }; this.send_request(data, spinner, function(response) { if (response.slots) { if (action === 'addpagebreak') { Y.Moodle.mod_quiz.util.page.add(activity); } else { var page = activity.next(Y.Moodle.mod_quiz.util.page.SELECTORS.PAGE); Y.Moodle.mod_quiz.util.page.remove(page, true); } this.reorganise_edit_page(); } }); return this; }, /** * Updates a slot to either require the question in the previous slot to * have been answered, or not, * * @protected * @method update_page_break * @param {EventFacade} ev The event that was fired. * @param {Node} button The button that triggered this action. * @param {Node} activity The activity node that this action will be performed on. * @param {String} action The action, adddependency or removedependency. * @chainable */ update_dependency: function(ev, button, activity, action) { // Prevent the default button action. ev.preventDefault(); var spinner = this.add_spinner(activity); var data = { 'class': 'resource', 'field': 'updatedependency', 'id': Y.Moodle.mod_quiz.util.slot.getId(activity), 'value': action === 'adddependency' ? 1 : 0 }; this.send_request(data, spinner, function(response) { if (response.hasOwnProperty('requireprevious')) { Y.Moodle.mod_quiz.util.slot.updateDependencyIcon(activity, response.requireprevious); } }); return this; }, /** * Reorganise the UI after every edit action. * * @protected * @method reorganise_edit_page */ reorganise_edit_page: function() { Y.Moodle.mod_quiz.util.slot.reorderSlots(); Y.Moodle.mod_quiz.util.slot.reorderPageBreaks(); Y.Moodle.mod_quiz.util.page.reorderPages(); Y.Moodle.mod_quiz.util.slot.updateOneSlotSections(); Y.Moodle.mod_quiz.util.slot.updateAllDependencyIcons(); }, NAME: 'mod_quiz-resource-toolbox', ATTRS: { courseid: { 'value': 0 }, quizid: { 'value': 0 } } }); M.mod_quiz.resource_toolbox = null; M.mod_quiz.init_resource_toolbox = function(config) { M.mod_quiz.resource_toolbox = new RESOURCETOOLBOX(config); return M.mod_quiz.resource_toolbox; }; /** * Section toolbox class. * * This class is responsible for managing AJAX interactions with sections * when adding, editing, removing section headings. * * @module moodle-mod_quiz-toolboxes * @namespace M.mod_quiz.toolboxes */ /** * Section toolbox class. * * This class is responsible for managing AJAX interactions with sections * when adding, editing, removing section headings when editing a quiz. * * @class section * @constructor * @extends M.mod_quiz.toolboxes.toolbox */ var SECTIONTOOLBOX = function() { SECTIONTOOLBOX.superclass.constructor.apply(this, arguments); }; Y.extend(SECTIONTOOLBOX, TOOLBOX, { /** * An Array of events added when editing a max mark field. * These should all be detached when editing is complete. * * @property editsectionevents * @protected * @type Array * @protected */ editsectionevents: [], /** * Initialize the section toolboxes module. * * Updates all span.commands with relevant handlers and other required changes. * * @method initializer * @protected */ initializer: function() { M.mod_quiz.quizbase.register_module(this); BODY.delegate('key', this.handle_data_action, 'down:enter', SELECTOR.ACTIVITYACTION, this); Y.delegate('click', this.handle_data_action, BODY, SELECTOR.ACTIVITYACTION, this); Y.delegate('change', this.handle_data_action, BODY, SELECTOR.EDITSHUFFLEQUESTIONSACTION, this); }, /** * Handles the delegation event. When this is fired someone has triggered an action. * * Note not all actions will result in an AJAX enhancement. * * @protected * @method handle_data_action * @param {EventFacade} ev The event that was triggered. * @returns {boolean} */ handle_data_action: function(ev) { // We need to get the anchor element that triggered this event. var node = ev.target; if (!node.test('a') && !node.test('input[data-action]')) { node = node.ancestor(SELECTOR.ACTIVITYACTION); } // From the anchor we can get both the activity (added during initialisation) and the action being // performed (added by the UI as a data attribute). var action = node.getData('action'), activity = node.ancestor(SELECTOR.ACTIVITYLI); if ((!node.test('a') && !node.test('input[data-action]')) || !action || !activity) { // It wasn't a valid action node. return; } // Switch based upon the action and do the desired thing. switch (action) { case 'edit_section_title': // The user wishes to edit the section headings. this.edit_section_title(ev, node, activity, action); break; case 'shuffle_questions': // The user wishes to edit the shuffle questions of the section (resource). this.edit_shuffle_questions(ev, node, activity, action); break; case 'deletesection': // The user is deleting the activity. this.delete_section_with_confirmation(ev, node, activity, action); break; default: // Nothing to do here! break; } }, /** * Deletes the given section heading after confirmation. * * @protected * @method delete_section_with_confirmation * @param {EventFacade} ev The event that was fired. * @param {Node} button The button that triggered this action. * @param {Node} activity The activity node that this action will be performed on. * @chainable */ delete_section_with_confirmation: function(ev, button, activity) { ev.preventDefault(); require(['core/notification'], function(Notification) { Notification.saveCancelPromise( M.util.get_string('confirm', 'moodle'), M.util.get_string('confirmremovesectionheading', 'quiz', activity.getData('sectionname')), M.util.get_string('yes', 'moodle') ).then(function() { var spinner = M.util.add_spinner(Y, activity.one(SELECTOR.ACTIONAREA)); var data = { 'class': 'section', 'action': 'DELETE', 'id': activity.get('id').replace('section-', '') }; this.send_request(data, spinner, function(response) { if (response.deleted) { window.location.reload(true); } }); return; }.bind(this)).catch(function() { // User cancelled. }); }.bind(this)); }, /** * Edit the edit section title for the section * * @protected * @method edit_section_title * @param {EventFacade} ev The event that was fired. * @param {Node} button The button that triggered this action. * @param {Node} activity The activity node that this action will be performed on. * @param {String} action The action that has been requested. * @return Boolean */ edit_section_title: function(ev, button, activity) { // Get the element we're working on var activityid = activity.get('id').replace('section-', ''), instancesection = activity.one(SELECTOR.INSTANCESECTION), thisevent, anchor = instancesection, // Grab the anchor so that we can swap it with the edit form. data = { 'class': 'section', 'field': 'getsectiontitle', 'id': activityid }; // Prevent the default actions. ev.preventDefault(); this.send_request(data, null, function(response) { // Try to retrieve the existing string from the server. var oldtext = response.instancesection; // Create the editor and submit button. var editform = Y.Node.create(''); var editinstructions = Y.Node.create('') .set('innerHTML', M.util.get_string('edittitleinstructions', 'moodle')); var editor = Y.Node.create('').setAttrs({ 'value': oldtext, 'autocomplete': 'off', 'aria-describedby': 'id_editinstructions', 'maxLength': '255' // This is the maxlength in DB. }); // Clear the existing content and put the editor in. editform.appendChild(editor); editform.setData('anchor', anchor); instancesection.insert(editinstructions, 'before'); anchor.replace(editform); // Focus and select the editor text. editor.focus().select(); // Cancel the edit if we lose focus or the escape key is pressed. thisevent = editor.on('blur', this.edit_section_title_cancel, this, activity, false); this.editsectionevents.push(thisevent); thisevent = editor.on('key', this.edit_section_title_cancel, 'esc', this, activity, true); this.editsectionevents.push(thisevent); // Handle form submission. thisevent = editform.on('submit', this.edit_section_title_submit, this, activity, oldtext); this.editsectionevents.push(thisevent); }); }, /** * Handles the submit event when editing section heading. * * @protected * @method edit_section_title_submiy * @param {EventFacade} ev The event that triggered this. * @param {Node} activity The activity whose maxmark we are altering. * @param {String} oldtext The original maxmark the activity or resource had. */ edit_section_title_submit: function(ev, activity, oldtext) { // We don't actually want to submit anything. ev.preventDefault(); var newtext = Y.Lang.trim(activity.one(SELECTOR.SECTIONFORM + ' ' + SELECTOR.SECTIONINPUT).get('value')); var spinner = M.util.add_spinner(Y, activity.one(SELECTOR.INSTANCESECTIONAREA)); this.edit_section_title_clear(activity); if (newtext !== null && newtext !== oldtext) { var instancesection = activity.one(SELECTOR.INSTANCESECTION); var instancesectiontext = newtext; if (newtext.trim() === '') { // Add a visually-hidden default section heading text to make sure we don't end up with an empty section heading. instancesectiontext = M.util.get_string('sectionnoname', 'quiz'); instancesection.addClass('visually-hidden'); } else { // Show the section heading when a non-empty value is set. instancesection.removeClass('visually-hidden'); } instancesection.setContent(instancesectiontext); var data = { 'class': 'section', 'field': 'updatesectiontitle', 'newheading': newtext, 'id': activity.get('id').replace('section-', '') }; this.send_request(data, spinner, function(response) { if (response) { // Set the content of the section heading if for some reason the response is different from the new text. // e.g. filters were applied, the update failed, etc. if (newtext !== response.instancesection) { if (response.instancesection.trim() === '') { // Add a visually-hidden default section heading text. instancesectiontext = M.util.get_string('sectionnoname', 'quiz'); instancesection.addClass('visually-hidden'); } else { instancesectiontext = response.instancesection; // Show the section heading when a non-empty value is set. instancesection.removeClass('visually-hidden'); } instancesection.setContent(instancesectiontext); } activity.one(SELECTOR.EDITSECTIONICON).set('title', M.util.get_string('sectionheadingedit', 'quiz', response.instancesection)); activity.one(SELECTOR.EDITSECTIONICON).set('alt', M.util.get_string('sectionheadingedit', 'quiz', response.instancesection)); var deleteicon = activity.one(SELECTOR.DELETESECTIONICON); if (deleteicon) { deleteicon.set('title', M.util.get_string('sectionheadingremove', 'quiz', response.instancesection)); deleteicon.set('alt', M.util.get_string('sectionheadingremove', 'quiz', response.instancesection)); } } }); } }, /** * Handles the cancel event when editing the activity or resources maxmark. * * @protected * @method edit_maxmark_cancel * @param {EventFacade} ev The event that triggered this. * @param {Node} activity The activity whose maxmark we are altering. * @param {Boolean} preventdefault If true we should prevent the default action from occuring. */ edit_section_title_cancel: function(ev, activity, preventdefault) { if (preventdefault) { ev.preventDefault(); } this.edit_section_title_clear(activity); }, /** * Handles clearing the editing UI and returning things to the original state they were in. * * @protected * @method edit_maxmark_clear * @param {Node} activity The activity whose maxmark we were altering. */ edit_section_title_clear: function(activity) { // Detach all listen events to prevent duplicate triggers new Y.EventHandle(this.editsectionevents).detach(); var editform = activity.one(SELECTOR.SECTIONFORM), instructions = activity.one('#id_editinstructions'); if (editform) { editform.replace(editform.getData('anchor')); } if (instructions) { instructions.remove(); } // Refocus the link which was clicked originally so the user can continue using keyboard nav. Y.later(100, this, function() { activity.one(SELECTOR.EDITSECTION).focus(); }); // This hack is to keep Behat happy until they release a version of // MinkSelenium2Driver that fixes // https://github.com/Behat/MinkSelenium2Driver/issues/80. if (!Y.one('input[name=section]')) { Y.one('body').append(''); } }, /** * Edit the edit shuffle questions for the section * * @protected * @method edit_shuffle_questions * @param {EventFacade} ev The event that was fired. * @param {Node} button The button that triggered this action. * @param {Node} activity The activity node that this action will be performed on. * @return Boolean */ edit_shuffle_questions: function(ev, button, activity) { var newvalue; if (activity.one(SELECTOR.EDITSHUFFLEQUESTIONSACTION).get('checked')) { newvalue = 1; activity.addClass('shuffled'); } else { newvalue = 0; activity.removeClass('shuffled'); } // Prevent the default actions. ev.preventDefault(); // Get the element we're working on var data = { 'class': 'section', 'field': 'updateshufflequestions', 'id': activity.get('id').replace('section-', ''), 'newshuffle': newvalue }; // Send request. var spinner = M.util.add_spinner(Y, activity.one(SELECTOR.EDITSHUFFLEAREA)); this.send_request(data, spinner); } }, { NAME: 'mod_quiz-section-toolbox', ATTRS: { courseid: { 'value': 0 }, quizid: { 'value': 0 } } }); M.mod_quiz.init_section_toolbox = function(config) { return new SECTIONTOOLBOX(config); }; }, '@VERSION@', { "requires": [ "base", "node", "event", "event-key", "io", "moodle-mod_quiz-quizbase", "moodle-mod_quiz-util-slot", "moodle-core-notification-ajaxexception" ] }); PK9B\퀬Fyui/build/moodle-mod_quiz-toolboxes/moodle-mod_quiz-toolboxes-debug.jsnu[YUI.add('moodle-mod_quiz-toolboxes', function (Y, NAME) { /* eslint-disable no-unused-vars */ /** * Resource and activity toolbox class. * * This class is responsible for managing AJAX interactions with activities and resources * when viewing a course in editing mode. * * @module moodle-course-toolboxes * @namespace M.course.toolboxes */ // The CSS classes we use. var CSS = { ACTIVITYINSTANCE: 'activityinstance', AVAILABILITYINFODIV: 'div.availabilityinfo', CONTENTWITHOUTLINK: 'contentwithoutlink', CONDITIONALHIDDEN: 'conditionalhidden', DIMCLASS: 'dimmed', DIMMEDTEXT: 'dimmed_text', EDITINSTRUCTIONS: 'editinstructions', EDITINGMAXMARK: 'editor_displayed', HIDE: 'hide', JOIN: 'page_join', MODINDENTCOUNT: 'mod-indent-', MODINDENTHUGE: 'mod-indent-huge', PAGE: 'page', SECTIONHIDDENCLASS: 'hidden', SECTIONIDPREFIX: 'section-', SELECTMULTIPLE: 'select-multiple', SLOT: 'slot', SHOW: 'editing_show', TITLEEDITOR: 'titleeditor' }, // The CSS selectors we use. SELECTOR = { ACTIONAREA: '.actions', ACTIONLINKTEXT: '.actionlinktext', ACTIVITYACTION: 'a.cm-edit-action[data-action], a.editing_maxmark, a.editing_section, input.shuffle_questions', ACTIVITYFORM: 'span.instancemaxmarkcontainer form', ACTIVITYINSTANCE: '.' + CSS.ACTIVITYINSTANCE, SECTIONINSTANCE: '.sectioninstance', ACTIVITYLI: 'li.activity, li.section', ACTIVITYMAXMARK: 'input[name=maxmark]', COMMANDSPAN: '.commands', CONTENTAFTERLINK: 'div.contentafterlink', CONTENTWITHOUTLINK: 'div.contentwithoutlink', DELETESECTIONICON: 'a.editing_delete .icon', EDITMAXMARK: 'a.editing_maxmark', EDITSECTION: 'a.editing_section', EDITSECTIONICON: 'a.editing_section .icon', EDITSHUFFLEQUESTIONSACTION: 'input.cm-edit-action[data-action]', EDITSHUFFLEAREA: '.instanceshufflequestions .shuffle-progress', HIDE: 'a.editing_hide', HIGHLIGHT: 'a.editing_highlight', INSTANCENAME: 'span.instancename', INSTANCEMAXMARK: 'span.instancemaxmark', INSTANCESECTION: 'span.instancesection', INSTANCESECTIONAREA: 'div.section-heading', MODINDENTDIV: '.mod-indent', MODINDENTOUTER: '.mod-indent-outer', NUMQUESTIONS: '.numberofquestions', PAGECONTENT: 'div#page-content', PAGELI: 'li.page', SECTIONLI: 'li.section', SECTIONUL: 'ul.section', SECTIONFORM: '.instancesectioncontainer form', SECTIONINPUT: 'input[name=section]', SELECTMULTIPLEBUTTON: '#selectmultiplecommand', SELECTMULTIPLECANCELBUTTON: '#selectmultiplecancelcommand', SELECTMULTIPLECHECKBOX: '.select-multiple-checkbox', SELECTMULTIPLEDELETEBUTTON: '#selectmultipledeletecommand', SELECTALL: '#questionselectall', SHOW: 'a.' + CSS.SHOW, SLOTLI: 'li.slot', SUMMARKS: '.mod_quiz_summarks' }, BODY = Y.one(document.body); // Setup the basic namespace. M.mod_quiz = M.mod_quiz || {}; /** * The toolbox class is a generic class which should never be directly * instantiated. Please extend it instead. * * @class toolbox * @constructor * @protected * @extends Base */ var TOOLBOX = function() { TOOLBOX.superclass.constructor.apply(this, arguments); }; Y.extend(TOOLBOX, Y.Base, { /** * Send a request using the REST API * * @method send_request * @param {Object} data The data to submit with the AJAX request * @param {Node} [statusspinner] A statusspinner which may contain a section loader * @param {Function} success_callback The callback to use on success * @param {Object} [optionalconfig] Any additional configuration to submit * @chainable */ send_request: function(data, statusspinner, success_callback, optionalconfig) { // Default data structure if (!data) { data = {}; } // Handle any variables which we must pass back through to var pageparams = this.get('config').pageparams, varname; for (varname in pageparams) { data[varname] = pageparams[varname]; } data.sesskey = M.cfg.sesskey; data.courseid = this.get('courseid'); data.quizid = this.get('quizid'); var uri = M.cfg.wwwroot + this.get('ajaxurl'); // Define the configuration to send with the request var responsetext = []; var config = { method: 'POST', data: data, on: { success: function(tid, response) { try { responsetext = Y.JSON.parse(response.responseText); if (responsetext.error) { new M.core.ajaxException(responsetext); } } catch (e) { // Ignore. } // Run the callback if we have one. if (responsetext.hasOwnProperty('newsummarks')) { Y.one(SELECTOR.SUMMARKS).setHTML(responsetext.newsummarks); } if (responsetext.hasOwnProperty('newnumquestions')) { Y.one(SELECTOR.NUMQUESTIONS).setHTML( M.util.get_string('numquestionsx', 'quiz', responsetext.newnumquestions) ); } if (success_callback) { Y.bind(success_callback, this, responsetext)(); } if (statusspinner) { window.setTimeout(function() { statusspinner.hide(); }, 400); } }, failure: function(tid, response) { if (statusspinner) { statusspinner.hide(); } new M.core.ajaxException(response); } }, context: this }; // Apply optional config if (optionalconfig) { for (varname in optionalconfig) { config[varname] = optionalconfig[varname]; } } if (statusspinner) { statusspinner.show(); } // Send the request Y.io(uri, config); return this; } }, { NAME: 'mod_quiz-toolbox', ATTRS: { /** * The ID of the Moodle Course being edited. * * @attribute courseid * @default 0 * @type Number */ courseid: { 'value': 0 }, /** * The Moodle course format. * * @attribute format * @default 'topics' * @type String */ quizid: { 'value': 0 }, /** * The URL to use when submitting requests. * @attribute ajaxurl * @default null * @type String */ ajaxurl: { 'value': null }, /** * Any additional configuration passed when creating the instance. * * @attribute config * @default {} * @type Object */ config: { 'value': {} } } } ); /** * Resource and activity toolbox class. * * This class is responsible for managing AJAX interactions with activities and resources * when viewing a quiz in editing mode. * * @module mod_quiz-resource-toolbox * @namespace M.mod_quiz.resource_toolbox */ /** * Resource and activity toolbox class. * * This is a class extending TOOLBOX containing code specific to resources * * This class is responsible for managing AJAX interactions with activities and resources * when viewing a quiz in editing mode. * * @class resources * @constructor * @extends M.course.toolboxes.toolbox */ var RESOURCETOOLBOX = function() { RESOURCETOOLBOX.superclass.constructor.apply(this, arguments); }; Y.extend(RESOURCETOOLBOX, TOOLBOX, { /** * An Array of events added when editing a max mark field. * These should all be detached when editing is complete. * * @property editmaxmarkevents * @protected * @type Array * @protected */ editmaxmarkevents: [], /** * */ NODE_PAGE: 1, NODE_SLOT: 2, NODE_JOIN: 3, /** * Initialize the resource toolbox * * For each activity the commands are updated and a reference to the activity is attached. * This way it doesn't matter where the commands are going to called from they have a reference to the * activity that they relate to. * This is essential as some of the actions are displayed in an actionmenu which removes them from the * page flow. * * This function also creates a single event delegate to manage all AJAX actions for all activities on * the page. * * @method initializer * @protected */ initializer: function() { M.mod_quiz.quizbase.register_module(this); Y.delegate('click', this.handle_data_action, BODY, SELECTOR.ACTIVITYACTION, this); Y.delegate('click', this.handle_data_action, BODY, SELECTOR.DEPENDENCY_LINK, this); this.initialise_select_multiple(); }, /** * Initialize the select multiple options * * Add actions to the buttons that enable multiple slots to be selected and managed at once. * * @method initialise_select_multiple * @protected */ initialise_select_multiple: function() { // Click select multiple button to show the select all options. Y.one(SELECTOR.SELECTMULTIPLEBUTTON).on('click', function(e) { e.preventDefault(); Y.one('body').addClass(CSS.SELECTMULTIPLE); }); // Click cancel button to show the select all options. Y.one(SELECTOR.SELECTMULTIPLECANCELBUTTON).on('click', function(e) { e.preventDefault(); Y.one('body').removeClass(CSS.SELECTMULTIPLE); }); // Assign the delete method to the delete multiple button. Y.delegate('click', this.delete_multiple_action, BODY, SELECTOR.SELECTMULTIPLEDELETEBUTTON, this); }, /** * Handles the delegation event. When this is fired someone has triggered an action. * * Note not all actions will result in an AJAX enhancement. * * @protected * @method handle_data_action * @param {EventFacade} ev The event that was triggered. * @returns {boolean} */ handle_data_action: function(ev) { // We need to get the anchor element that triggered this event. var node = ev.target; if (!node.test('a')) { node = node.ancestor(SELECTOR.ACTIVITYACTION); } // From the anchor we can get both the activity (added during initialisation) and the action being // performed (added by the UI as a data attribute). var action = node.getData('action'), activity = node.ancestor(SELECTOR.ACTIVITYLI); if (!node.test('a') || !action || !activity) { // It wasn't a valid action node. return; } // Switch based upon the action and do the desired thing. switch (action) { case 'editmaxmark': // The user wishes to edit the maxmark of the resource. this.edit_maxmark(ev, node, activity, action); break; case 'delete': // The user is deleting the activity. this.delete_with_confirmation(ev, node, activity, action); break; case 'addpagebreak': case 'removepagebreak': // The user is adding or removing a page break. this.update_page_break(ev, node, activity, action); break; case 'adddependency': case 'removedependency': // The user is adding or removing a dependency between questions. this.update_dependency(ev, node, activity, action); break; default: // Nothing to do here! break; } }, /** * Add a loading icon to the specified activity. * * The icon is added within the action area. * * @method add_spinner * @param {Node} activity The activity to add a loading icon to * @return {Node|null} The newly created icon, or null if the action area was not found. */ add_spinner: function(activity) { var actionarea = activity.one(SELECTOR.ACTIONAREA); if (actionarea) { return M.util.add_spinner(Y, actionarea); } return null; }, /** * Deletes the given activity or resource after confirmation. * * @protected * @method delete_with_confirmation * @param {EventFacade} ev The event that was fired. * @param {Node} button The button that triggered this action. * @param {Node} activity The activity node that this action will be performed on. */ delete_with_confirmation: function(ev, button, activity) { // Prevent the default button action. ev.preventDefault(); // Get the element we're working on. var element = activity; // Create confirm string (different if element has or does not have name) var qtypename = M.util.get_string( 'pluginname', 'qtype_' + element.getAttribute('class').match(/qtype_([^\s]*)/)[1] ); // Create the confirmation dialogue. require(['core/notification'], function(Notification) { Notification.saveCancelPromise( M.util.get_string('confirm', 'moodle'), M.util.get_string('confirmremovequestion', 'quiz', qtypename), M.util.get_string('yes', 'moodle') ).then(function() { var spinner = this.add_spinner(element); var data = { 'class': 'resource', 'action': 'DELETE', 'id': Y.Moodle.mod_quiz.util.slot.getId(element) }; this.send_request(data, spinner, function(response) { if (response.deleted) { // Actually remove the element. Y.Moodle.mod_quiz.util.slot.remove(element); this.reorganise_edit_page(); if (M.core.actionmenu && M.core.actionmenu.instance) { M.core.actionmenu.instance.hideMenu(ev); } } }); return; }.bind(this)).catch(function() { // User cancelled. }); }.bind(this)); }, /** * Finds the section that would become empty if we remove the selected slots. * * @protected * @method find_sections_that_would_become_empty * @returns {String} The name of the first section found */ find_sections_that_would_become_empty: function() { var section; var sectionnodes = Y.all(SELECTOR.SECTIONLI); if (sectionnodes.size() > 1) { sectionnodes.some(function(node) { var sectionname = node.one(SELECTOR.INSTANCESECTION).getContent(); var checked = node.all(SELECTOR.SELECTMULTIPLECHECKBOX + ':checked'); var unchecked = node.all(SELECTOR.SELECTMULTIPLECHECKBOX + ':not(:checked)'); if (!checked.isEmpty() && unchecked.isEmpty()) { section = sectionname; } return section; }); } return section; }, /** * Takes care of what needs to happen when the user clicks on the delete multiple button. * * @protected * @method delete_multiple_action * @param {EventFacade} ev The event that was fired. */ delete_multiple_action: function(ev) { var problemsection = this.find_sections_that_would_become_empty(); if (typeof problemsection !== 'undefined') { require(['core/notification'], function(Notification) { Notification.alert( M.util.get_string('cannotremoveslots', 'quiz'), M.util.get_string('cannotremoveallsectionslots', 'quiz', problemsection) ); }); } else { this.delete_multiple_with_confirmation(ev); } }, /** * Deletes the given activities or resources after confirmation. * * @protected * @method delete_multiple_with_confirmation * @param {EventFacade} ev The event that was fired. */ delete_multiple_with_confirmation: function(ev) { ev.preventDefault(); var ids = ''; var slots = []; Y.all(SELECTOR.SELECTMULTIPLECHECKBOX + ':checked').each(function(node) { var slot = Y.Moodle.mod_quiz.util.slot.getSlotFromComponent(node); ids += ids === '' ? '' : ','; ids += Y.Moodle.mod_quiz.util.slot.getId(slot); slots.push(slot); }); var element = Y.one('div.mod-quiz-edit-content'); // Do nothing if no slots are selected. if (!slots || !slots.length) { return; } require(['core/notification'], function(Notification) { Notification.saveCancelPromise( M.util.get_string('confirm', 'moodle'), M.util.get_string('areyousureremoveselected', 'quiz'), M.util.get_string('yes', 'moodle') ).then(function() { var spinner = this.add_spinner(element); var data = { 'class': 'resource', field: 'deletemultiple', ids: ids }; // Delete items on server. this.send_request(data, spinner, function(response) { // Delete locally if deleted on server. if (response.deleted) { // Actually remove the element. Y.all(SELECTOR.SELECTMULTIPLECHECKBOX + ':checked').each(function(node) { Y.Moodle.mod_quiz.util.slot.remove(node.ancestor('li.activity')); }); // Update the page numbers and sections. this.reorganise_edit_page(); // Remove the select multiple options. Y.one('body').removeClass(CSS.SELECTMULTIPLE); } }); return; }.bind(this)).catch(function() { // User cancelled. }); }.bind(this)); }, /** * Edit the maxmark for the resource * * @protected * @method edit_maxmark * @param {EventFacade} ev The event that was fired. * @param {Node} button The button that triggered this action. * @param {Node} activity The activity node that this action will be performed on. * @param {String} action The action that has been requested. * @return Boolean */ edit_maxmark: function(ev, button, activity) { // Get the element we're working on var instancemaxmark = activity.one(SELECTOR.INSTANCEMAXMARK), instance = activity.one(SELECTOR.ACTIVITYINSTANCE), currentmaxmark = instancemaxmark.get('firstChild'), oldmaxmark = currentmaxmark.get('data'), maxmarktext = oldmaxmark, thisevent, anchor = instancemaxmark, // Grab the anchor so that we can swap it with the edit form. data = { 'class': 'resource', 'field': 'getmaxmark', 'id': Y.Moodle.mod_quiz.util.slot.getId(activity) }; // Prevent the default actions. ev.preventDefault(); this.send_request(data, null, function(response) { if (M.core.actionmenu && M.core.actionmenu.instance) { M.core.actionmenu.instance.hideMenu(ev); } // Try to retrieve the existing string from the server. if (response.instancemaxmark) { maxmarktext = response.instancemaxmark; } // Create the editor and submit button. var editform = Y.Node.create(''); var editinstructions = Y.Node.create('') .set('innerHTML', M.util.get_string('edittitleinstructions', 'moodle')); var editor = Y.Node.create('').setAttrs({ 'value': maxmarktext, 'autocomplete': 'off', 'aria-describedby': 'id_editinstructions', 'maxLength': '12', 'size': parseInt(this.get('config').questiondecimalpoints, 10) + 2 }); // Clear the existing content and put the editor in. editform.appendChild(editor); editform.setData('anchor', anchor); instance.insert(editinstructions, 'before'); anchor.replace(editform); // We hide various components whilst editing: activity.addClass(CSS.EDITINGMAXMARK); // Focus and select the editor text. editor.focus().select(); // Cancel the edit if we lose focus or the escape key is pressed. thisevent = editor.on('blur', this.edit_maxmark_cancel, this, activity, false); this.editmaxmarkevents.push(thisevent); thisevent = editor.on('key', this.edit_maxmark_cancel, 'esc', this, activity, true); this.editmaxmarkevents.push(thisevent); // Handle form submission. thisevent = editform.on('submit', this.edit_maxmark_submit, this, activity, oldmaxmark); this.editmaxmarkevents.push(thisevent); }); }, /** * Handles the submit event when editing the activity or resources maxmark. * * @protected * @method edit_maxmark_submit * @param {EventFacade} ev The event that triggered this. * @param {Node} activity The activity whose maxmark we are altering. * @param {String} originalmaxmark The original maxmark the activity or resource had. */ edit_maxmark_submit: function(ev, activity, originalmaxmark) { // We don't actually want to submit anything. ev.preventDefault(); var newmaxmark = Y.Lang.trim(activity.one(SELECTOR.ACTIVITYFORM + ' ' + SELECTOR.ACTIVITYMAXMARK).get('value')); var spinner = this.add_spinner(activity); this.edit_maxmark_clear(activity); activity.one(SELECTOR.INSTANCEMAXMARK).setContent(newmaxmark); if (newmaxmark !== null && newmaxmark !== "" && newmaxmark !== originalmaxmark) { var data = { 'class': 'resource', 'field': 'updatemaxmark', 'maxmark': newmaxmark, 'id': Y.Moodle.mod_quiz.util.slot.getId(activity) }; this.send_request(data, spinner, function(response) { if (response.instancemaxmark) { activity.one(SELECTOR.INSTANCEMAXMARK).setContent(response.instancemaxmark); } }); } }, /** * Handles the cancel event when editing the activity or resources maxmark. * * @protected * @method edit_maxmark_cancel * @param {EventFacade} ev The event that triggered this. * @param {Node} activity The activity whose maxmark we are altering. * @param {Boolean} preventdefault If true we should prevent the default action from occuring. */ edit_maxmark_cancel: function(ev, activity, preventdefault) { if (preventdefault) { ev.preventDefault(); } this.edit_maxmark_clear(activity); }, /** * Handles clearing the editing UI and returning things to the original state they were in. * * @protected * @method edit_maxmark_clear * @param {Node} activity The activity whose maxmark we were altering. */ edit_maxmark_clear: function(activity) { // Detach all listen events to prevent duplicate triggers new Y.EventHandle(this.editmaxmarkevents).detach(); var editform = activity.one(SELECTOR.ACTIVITYFORM), instructions = activity.one('#id_editinstructions'); if (editform) { editform.replace(editform.getData('anchor')); } if (instructions) { instructions.remove(); } // Remove the editing class again to revert the display. activity.removeClass(CSS.EDITINGMAXMARK); // Refocus the link which was clicked originally so the user can continue using keyboard nav. Y.later(100, this, function() { activity.one(SELECTOR.EDITMAXMARK).focus(); }); // TODO MDL-50768 This hack is to keep Behat happy until they release a version of // MinkSelenium2Driver that fixes // https://github.com/Behat/MinkSelenium2Driver/issues/80. if (!Y.one('input[name=maxmark')) { Y.one('body').append(''); } }, /** * Joins or separates the given slot with the page of the previous slot. Reorders the pages of * the other slots * * @protected * @method update_page_break * @param {EventFacade} ev The event that was fired. * @param {Node} button The button that triggered this action. * @param {Node} activity The activity node that this action will be performed on. * @param {String} action The action, addpagebreak or removepagebreak. * @chainable */ update_page_break: function(ev, button, activity, action) { // Prevent the default button action ev.preventDefault(); var nextactivity = activity.next('li.activity.slot'); var spinner = this.add_spinner(nextactivity); var value = action === 'removepagebreak' ? 1 : 2; var data = { 'class': 'resource', 'field': 'updatepagebreak', 'id': Y.Moodle.mod_quiz.util.slot.getId(nextactivity), 'value': value }; this.send_request(data, spinner, function(response) { if (response.slots) { if (action === 'addpagebreak') { Y.Moodle.mod_quiz.util.page.add(activity); } else { var page = activity.next(Y.Moodle.mod_quiz.util.page.SELECTORS.PAGE); Y.Moodle.mod_quiz.util.page.remove(page, true); } this.reorganise_edit_page(); } }); return this; }, /** * Updates a slot to either require the question in the previous slot to * have been answered, or not, * * @protected * @method update_page_break * @param {EventFacade} ev The event that was fired. * @param {Node} button The button that triggered this action. * @param {Node} activity The activity node that this action will be performed on. * @param {String} action The action, adddependency or removedependency. * @chainable */ update_dependency: function(ev, button, activity, action) { // Prevent the default button action. ev.preventDefault(); var spinner = this.add_spinner(activity); var data = { 'class': 'resource', 'field': 'updatedependency', 'id': Y.Moodle.mod_quiz.util.slot.getId(activity), 'value': action === 'adddependency' ? 1 : 0 }; this.send_request(data, spinner, function(response) { if (response.hasOwnProperty('requireprevious')) { Y.Moodle.mod_quiz.util.slot.updateDependencyIcon(activity, response.requireprevious); } }); return this; }, /** * Reorganise the UI after every edit action. * * @protected * @method reorganise_edit_page */ reorganise_edit_page: function() { Y.Moodle.mod_quiz.util.slot.reorderSlots(); Y.Moodle.mod_quiz.util.slot.reorderPageBreaks(); Y.Moodle.mod_quiz.util.page.reorderPages(); Y.Moodle.mod_quiz.util.slot.updateOneSlotSections(); Y.Moodle.mod_quiz.util.slot.updateAllDependencyIcons(); }, NAME: 'mod_quiz-resource-toolbox', ATTRS: { courseid: { 'value': 0 }, quizid: { 'value': 0 } } }); M.mod_quiz.resource_toolbox = null; M.mod_quiz.init_resource_toolbox = function(config) { M.mod_quiz.resource_toolbox = new RESOURCETOOLBOX(config); return M.mod_quiz.resource_toolbox; }; /** * Section toolbox class. * * This class is responsible for managing AJAX interactions with sections * when adding, editing, removing section headings. * * @module moodle-mod_quiz-toolboxes * @namespace M.mod_quiz.toolboxes */ /** * Section toolbox class. * * This class is responsible for managing AJAX interactions with sections * when adding, editing, removing section headings when editing a quiz. * * @class section * @constructor * @extends M.mod_quiz.toolboxes.toolbox */ var SECTIONTOOLBOX = function() { SECTIONTOOLBOX.superclass.constructor.apply(this, arguments); }; Y.extend(SECTIONTOOLBOX, TOOLBOX, { /** * An Array of events added when editing a max mark field. * These should all be detached when editing is complete. * * @property editsectionevents * @protected * @type Array * @protected */ editsectionevents: [], /** * Initialize the section toolboxes module. * * Updates all span.commands with relevant handlers and other required changes. * * @method initializer * @protected */ initializer: function() { M.mod_quiz.quizbase.register_module(this); BODY.delegate('key', this.handle_data_action, 'down:enter', SELECTOR.ACTIVITYACTION, this); Y.delegate('click', this.handle_data_action, BODY, SELECTOR.ACTIVITYACTION, this); Y.delegate('change', this.handle_data_action, BODY, SELECTOR.EDITSHUFFLEQUESTIONSACTION, this); }, /** * Handles the delegation event. When this is fired someone has triggered an action. * * Note not all actions will result in an AJAX enhancement. * * @protected * @method handle_data_action * @param {EventFacade} ev The event that was triggered. * @returns {boolean} */ handle_data_action: function(ev) { // We need to get the anchor element that triggered this event. var node = ev.target; if (!node.test('a') && !node.test('input[data-action]')) { node = node.ancestor(SELECTOR.ACTIVITYACTION); } // From the anchor we can get both the activity (added during initialisation) and the action being // performed (added by the UI as a data attribute). var action = node.getData('action'), activity = node.ancestor(SELECTOR.ACTIVITYLI); if ((!node.test('a') && !node.test('input[data-action]')) || !action || !activity) { // It wasn't a valid action node. return; } // Switch based upon the action and do the desired thing. switch (action) { case 'edit_section_title': // The user wishes to edit the section headings. this.edit_section_title(ev, node, activity, action); break; case 'shuffle_questions': // The user wishes to edit the shuffle questions of the section (resource). this.edit_shuffle_questions(ev, node, activity, action); break; case 'deletesection': // The user is deleting the activity. this.delete_section_with_confirmation(ev, node, activity, action); break; default: // Nothing to do here! break; } }, /** * Deletes the given section heading after confirmation. * * @protected * @method delete_section_with_confirmation * @param {EventFacade} ev The event that was fired. * @param {Node} button The button that triggered this action. * @param {Node} activity The activity node that this action will be performed on. * @chainable */ delete_section_with_confirmation: function(ev, button, activity) { ev.preventDefault(); require(['core/notification'], function(Notification) { Notification.saveCancelPromise( M.util.get_string('confirm', 'moodle'), M.util.get_string('confirmremovesectionheading', 'quiz', activity.getData('sectionname')), M.util.get_string('yes', 'moodle') ).then(function() { var spinner = M.util.add_spinner(Y, activity.one(SELECTOR.ACTIONAREA)); var data = { 'class': 'section', 'action': 'DELETE', 'id': activity.get('id').replace('section-', '') }; this.send_request(data, spinner, function(response) { if (response.deleted) { window.location.reload(true); } }); return; }.bind(this)).catch(function() { // User cancelled. }); }.bind(this)); }, /** * Edit the edit section title for the section * * @protected * @method edit_section_title * @param {EventFacade} ev The event that was fired. * @param {Node} button The button that triggered this action. * @param {Node} activity The activity node that this action will be performed on. * @param {String} action The action that has been requested. * @return Boolean */ edit_section_title: function(ev, button, activity) { // Get the element we're working on var activityid = activity.get('id').replace('section-', ''), instancesection = activity.one(SELECTOR.INSTANCESECTION), thisevent, anchor = instancesection, // Grab the anchor so that we can swap it with the edit form. data = { 'class': 'section', 'field': 'getsectiontitle', 'id': activityid }; // Prevent the default actions. ev.preventDefault(); this.send_request(data, null, function(response) { // Try to retrieve the existing string from the server. var oldtext = response.instancesection; // Create the editor and submit button. var editform = Y.Node.create(''); var editinstructions = Y.Node.create('') .set('innerHTML', M.util.get_string('edittitleinstructions', 'moodle')); var editor = Y.Node.create('').setAttrs({ 'value': oldtext, 'autocomplete': 'off', 'aria-describedby': 'id_editinstructions', 'maxLength': '255' // This is the maxlength in DB. }); // Clear the existing content and put the editor in. editform.appendChild(editor); editform.setData('anchor', anchor); instancesection.insert(editinstructions, 'before'); anchor.replace(editform); // Focus and select the editor text. editor.focus().select(); // Cancel the edit if we lose focus or the escape key is pressed. thisevent = editor.on('blur', this.edit_section_title_cancel, this, activity, false); this.editsectionevents.push(thisevent); thisevent = editor.on('key', this.edit_section_title_cancel, 'esc', this, activity, true); this.editsectionevents.push(thisevent); // Handle form submission. thisevent = editform.on('submit', this.edit_section_title_submit, this, activity, oldtext); this.editsectionevents.push(thisevent); }); }, /** * Handles the submit event when editing section heading. * * @protected * @method edit_section_title_submiy * @param {EventFacade} ev The event that triggered this. * @param {Node} activity The activity whose maxmark we are altering. * @param {String} oldtext The original maxmark the activity or resource had. */ edit_section_title_submit: function(ev, activity, oldtext) { // We don't actually want to submit anything. ev.preventDefault(); var newtext = Y.Lang.trim(activity.one(SELECTOR.SECTIONFORM + ' ' + SELECTOR.SECTIONINPUT).get('value')); var spinner = M.util.add_spinner(Y, activity.one(SELECTOR.INSTANCESECTIONAREA)); this.edit_section_title_clear(activity); if (newtext !== null && newtext !== oldtext) { var instancesection = activity.one(SELECTOR.INSTANCESECTION); var instancesectiontext = newtext; if (newtext.trim() === '') { // Add a visually-hidden default section heading text to make sure we don't end up with an empty section heading. instancesectiontext = M.util.get_string('sectionnoname', 'quiz'); instancesection.addClass('visually-hidden'); } else { // Show the section heading when a non-empty value is set. instancesection.removeClass('visually-hidden'); } instancesection.setContent(instancesectiontext); var data = { 'class': 'section', 'field': 'updatesectiontitle', 'newheading': newtext, 'id': activity.get('id').replace('section-', '') }; this.send_request(data, spinner, function(response) { if (response) { // Set the content of the section heading if for some reason the response is different from the new text. // e.g. filters were applied, the update failed, etc. if (newtext !== response.instancesection) { if (response.instancesection.trim() === '') { // Add a visually-hidden default section heading text. instancesectiontext = M.util.get_string('sectionnoname', 'quiz'); instancesection.addClass('visually-hidden'); } else { instancesectiontext = response.instancesection; // Show the section heading when a non-empty value is set. instancesection.removeClass('visually-hidden'); } instancesection.setContent(instancesectiontext); } activity.one(SELECTOR.EDITSECTIONICON).set('title', M.util.get_string('sectionheadingedit', 'quiz', response.instancesection)); activity.one(SELECTOR.EDITSECTIONICON).set('alt', M.util.get_string('sectionheadingedit', 'quiz', response.instancesection)); var deleteicon = activity.one(SELECTOR.DELETESECTIONICON); if (deleteicon) { deleteicon.set('title', M.util.get_string('sectionheadingremove', 'quiz', response.instancesection)); deleteicon.set('alt', M.util.get_string('sectionheadingremove', 'quiz', response.instancesection)); } } }); } }, /** * Handles the cancel event when editing the activity or resources maxmark. * * @protected * @method edit_maxmark_cancel * @param {EventFacade} ev The event that triggered this. * @param {Node} activity The activity whose maxmark we are altering. * @param {Boolean} preventdefault If true we should prevent the default action from occuring. */ edit_section_title_cancel: function(ev, activity, preventdefault) { if (preventdefault) { ev.preventDefault(); } this.edit_section_title_clear(activity); }, /** * Handles clearing the editing UI and returning things to the original state they were in. * * @protected * @method edit_maxmark_clear * @param {Node} activity The activity whose maxmark we were altering. */ edit_section_title_clear: function(activity) { // Detach all listen events to prevent duplicate triggers new Y.EventHandle(this.editsectionevents).detach(); var editform = activity.one(SELECTOR.SECTIONFORM), instructions = activity.one('#id_editinstructions'); if (editform) { editform.replace(editform.getData('anchor')); } if (instructions) { instructions.remove(); } // Refocus the link which was clicked originally so the user can continue using keyboard nav. Y.later(100, this, function() { activity.one(SELECTOR.EDITSECTION).focus(); }); // This hack is to keep Behat happy until they release a version of // MinkSelenium2Driver that fixes // https://github.com/Behat/MinkSelenium2Driver/issues/80. if (!Y.one('input[name=section]')) { Y.one('body').append(''); } }, /** * Edit the edit shuffle questions for the section * * @protected * @method edit_shuffle_questions * @param {EventFacade} ev The event that was fired. * @param {Node} button The button that triggered this action. * @param {Node} activity The activity node that this action will be performed on. * @return Boolean */ edit_shuffle_questions: function(ev, button, activity) { var newvalue; if (activity.one(SELECTOR.EDITSHUFFLEQUESTIONSACTION).get('checked')) { newvalue = 1; activity.addClass('shuffled'); } else { newvalue = 0; activity.removeClass('shuffled'); } // Prevent the default actions. ev.preventDefault(); // Get the element we're working on var data = { 'class': 'section', 'field': 'updateshufflequestions', 'id': activity.get('id').replace('section-', ''), 'newshuffle': newvalue }; // Send request. var spinner = M.util.add_spinner(Y, activity.one(SELECTOR.EDITSHUFFLEAREA)); this.send_request(data, spinner); } }, { NAME: 'mod_quiz-section-toolbox', ATTRS: { courseid: { 'value': 0 }, quizid: { 'value': 0 } } }); M.mod_quiz.init_section_toolbox = function(config) { return new SECTIONTOOLBOX(config); }; }, '@VERSION@', { "requires": [ "base", "node", "event", "event-key", "io", "moodle-mod_quiz-quizbase", "moodle-mod_quiz-util-slot", "moodle-core-notification-ajaxexception" ] }); PK9B\@x"7"7Dyui/build/moodle-mod_quiz-toolboxes/moodle-mod_quiz-toolboxes-min.jsnu[YUI.add("moodle-mod_quiz-toolboxes",function(u,a){var e,t,i,n="activityinstance",r="editinstructions",m="editor_displayed",o="select-multiple",c="editing_show",h="titleeditor",_={ACTIONAREA:".actions",ACTIONLINKTEXT:".actionlinktext",ACTIVITYACTION:"a.cm-edit-action[data-action], a.editing_maxmark, a.editing_section, input.shuffle_questions",ACTIVITYFORM:"span.instancemaxmarkcontainer form",ACTIVITYINSTANCE:"."+n,SECTIONINSTANCE:".sectioninstance",ACTIVITYLI:"li.activity, li.section",ACTIVITYMAXMARK:"input[name=maxmark]",COMMANDSPAN:".commands",CONTENTAFTERLINK:"div.contentafterlink",CONTENTWITHOUTLINK:"div.contentwithoutlink",DELETESECTIONICON:"a.editing_delete .icon",EDITMAXMARK:"a.editing_maxmark",EDITSECTION:"a.editing_section",EDITSECTIONICON:"a.editing_section .icon",EDITSHUFFLEQUESTIONSACTION:"input.cm-edit-action[data-action]",EDITSHUFFLEAREA:".instanceshufflequestions .shuffle-progress",HIDE:"a.editing_hide",HIGHLIGHT:"a.editing_highlight",INSTANCENAME:"span.instancename",INSTANCEMAXMARK:"span.instancemaxmark",INSTANCESECTION:"span.instancesection",INSTANCESECTIONAREA:"div.section-heading",MODINDENTDIV:".mod-indent",MODINDENTOUTER:".mod-indent-outer",NUMQUESTIONS:".numberofquestions",PAGECONTENT:"div#page-content",PAGELI:"li.page",SECTIONLI:"li.section",SECTIONUL:"ul.section",SECTIONFORM:".instancesectioncontainer form",SECTIONINPUT:"input[name=section]",SELECTMULTIPLEBUTTON:"#selectmultiplecommand",SELECTMULTIPLECANCELBUTTON:"#selectmultiplecancelcommand",SELECTMULTIPLECHECKBOX:".select-multiple-checkbox",SELECTMULTIPLEDELETEBUTTON:"#selectmultipledeletecommand",SELECTALL:"#questionselectall",SHOW:"a."+c,SLOTLI:"li.slot",SUMMARKS:".mod_quiz_summarks"},s=u.one(document.body);M.mod_quiz=M.mod_quiz||{},u.extend(e=function(){e.superclass.constructor.apply(this,arguments)},u.Base,{send_request:function(e,n,o,t){var i,s,a,c,d;for(s in e=e||{},i=this.get("config").pageparams)e[s]=i[s];if(e.sesskey=M.cfg.sesskey,e.courseid=this.get("courseid"),e.quizid=this.get("quizid"),a=M.cfg.wwwroot+this.get("ajaxurl"),c=[],d={method:"POST",data:e,on:{success:function(e,t){try{(c=u.JSON.parse(t.responseText)).error&&new M.core.ajaxException(c)}catch(i){}c.hasOwnProperty("newsummarks")&&u.one(_.SUMMARKS).setHTML(c.newsummarks),c.hasOwnProperty("newnumquestions")&&u.one(_.NUMQUESTIONS).setHTML(M.util.get_string("numquestionsx","quiz",c.newnumquestions)),o&&u.bind(o,this,c)(),n&&window.setTimeout(function(){n.hide()},400)},failure:function(e,t){n&&n.hide(),new M.core.ajaxException(t)}},context:this},t)for(s in t)d[s]=t[s];return n&&n.show(),u.io(a,d),this}},{NAME:"mod_quiz-toolbox",ATTRS:{courseid:{value:0},quizid:{value:0},ajaxurl:{value:null},config:{value:{}}}}),u.extend(t=function(){t.superclass.constructor.apply(this,arguments)},e,{editmaxmarkevents:[],NODE_PAGE:1,NODE_SLOT:2,NODE_JOIN:3,initializer:function(){M.mod_quiz.quizbase.register_module(this),u.delegate("click",this.handle_data_action,s,_.ACTIVITYACTION,this),u.delegate("click",this.handle_data_action,s,_.DEPENDENCY_LINK,this),this.initialise_select_multiple()},initialise_select_multiple:function(){u.one(_.SELECTMULTIPLEBUTTON).on("click",function(e){e.preventDefault(),u.one("body").addClass(o)}),u.one(_.SELECTMULTIPLECANCELBUTTON).on("click",function(e){e.preventDefault(),u.one("body").removeClass(o)}),u.delegate("click",this.delete_multiple_action,s,_.SELECTMULTIPLEDELETEBUTTON,this)},handle_data_action:function(e){var t=e.target,i=(t=t.test("a")?t:t.ancestor(_.ACTIVITYACTION)).getData("action"),n=t.ancestor(_.ACTIVITYLI);if(t.test("a")&&i&&n)switch(i){case"editmaxmark":this.edit_maxmark(e,t,n,i);break;case"delete":this.delete_with_confirmation(e,t,n,i);break;case"addpagebreak":case"removepagebreak":this.update_page_break(e,t,n,i);break;case"adddependency":case"removedependency":this.update_dependency(e,t,n,i)}},add_spinner:function(e){e=e.one(_.ACTIONAREA);return e?M.util.add_spinner(u,e):null},delete_with_confirmation:function(i,e,t){var n,o;i.preventDefault(),n=t,o=M.util.get_string("pluginname","qtype_"+n.getAttribute("class").match(/qtype_([^\s]*)/)[1]),require(["core/notification"],function(e){e.saveCancelPromise(M.util.get_string("confirm","moodle"),M.util.get_string("confirmremovequestion","quiz",o),M.util.get_string("yes","moodle")).then(function(){var e=this.add_spinner(n),t={"class":"resource",action:"DELETE",id:u.Moodle.mod_quiz.util.slot.getId(n)};this.send_request(t,e,function(e){e.deleted&&(u.Moodle.mod_quiz.util.slot.remove(n),this.reorganise_edit_page(),M.core.actionmenu&&M.core.actionmenu.instance&&M.core.actionmenu.instance.hideMenu(i))})}.bind(this))["catch"](function(){})}.bind(this))},find_sections_that_would_become_empty:function(){var n,e=u.all(_.SECTIONLI);return 1'),t=u.Node.create('').set("innerHTML",M.util.get_string("edittitleinstructions","moodle")),i=u.Node.create('').setAttrs({value:d,autocomplete:"off","aria-describedby":"id_editinstructions",maxLength:"12",size:parseInt(this.get("config").questiondecimalpoints,10)+2}),e.appendChild(i),e.setData("anchor",l),a.insert(t,"before"),l.replace(e),o.addClass(m),i.focus().select(),s=i.on("blur",this.edit_maxmark_cancel,this,o,!1),this.editmaxmarkevents.push(s),s=i.on("key",this.edit_maxmark_cancel,"esc",this,o,!0),this.editmaxmarkevents.push(s),s=e.on("submit",this.edit_maxmark_submit,this,o,c),this.editmaxmarkevents.push(s)})},edit_maxmark_submit:function(e,t,i){var n;e.preventDefault(),e=u.Lang.trim(t.one(_.ACTIVITYFORM+" "+_.ACTIVITYMAXMARK).get("value")),n=this.add_spinner(t),this.edit_maxmark_clear(t),t.one(_.INSTANCEMAXMARK).setContent(e),null!==e&&""!==e&&e!==i&&(i={"class":"resource",field:"updatemaxmark",maxmark:e,id:u.Moodle.mod_quiz.util.slot.getId(t)},this.send_request(i,n,function(e){e.instancemaxmark&&t.one(_.INSTANCEMAXMARK).setContent(e.instancemaxmark)}))},edit_maxmark_cancel:function(e,t,i){i&&e.preventDefault(),this.edit_maxmark_clear(t)},edit_maxmark_clear:function(e){new u.EventHandle(this.editmaxmarkevents).detach();var t=e.one(_.ACTIVITYFORM),i=e.one("#id_editinstructions");t&&t.replace(t.getData("anchor")),i&&i.remove(),e.removeClass(m),u.later(100,this,function(){e.one(_.EDITMAXMARK).focus()}),u.one("input[name=maxmark")||u.one("body").append('')},update_page_break:function(e,t,i,n){var o,s;return e.preventDefault(),e=i.next("li.activity.slot"),o=this.add_spinner(e),s="removepagebreak"===n?1:2,e={"class":"resource",field:"updatepagebreak",id:u.Moodle.mod_quiz.util.slot.getId(e),value:s},this.send_request(e,o,function(e){e.slots&&("addpagebreak"===n?u.Moodle.mod_quiz.util.page.add(i):(e=i.next(u.Moodle.mod_quiz.util.page.SELECTORS.PAGE),u.Moodle.mod_quiz.util.page.remove(e,!0)),this.reorganise_edit_page())}),this},update_dependency:function(e,t,i,n){return e.preventDefault(),e=this.add_spinner(i),n={"class":"resource",field:"updatedependency",id:u.Moodle.mod_quiz.util.slot.getId(i),value:"adddependency"===n?1:0},this.send_request(n,e,function(e){e.hasOwnProperty("requireprevious")&&u.Moodle.mod_quiz.util.slot.updateDependencyIcon(i,e.requireprevious)}),this},reorganise_edit_page:function(){u.Moodle.mod_quiz.util.slot.reorderSlots(),u.Moodle.mod_quiz.util.slot.reorderPageBreaks(),u.Moodle.mod_quiz.util.page.reorderPages(),u.Moodle.mod_quiz.util.slot.updateOneSlotSections(),u.Moodle.mod_quiz.util.slot.updateAllDependencyIcons()},NAME:"mod_quiz-resource-toolbox",ATTRS:{courseid:{value:0},quizid:{value:0}}}),M.mod_quiz.resource_toolbox=null,M.mod_quiz.init_resource_toolbox=function(e){return M.mod_quiz.resource_toolbox=new t(e),M.mod_quiz.resource_toolbox},u.extend(i=function(){i.superclass.constructor.apply(this,arguments)},e,{editsectionevents:[],initializer:function(){M.mod_quiz.quizbase.register_module(this),s.delegate("key",this.handle_data_action,"down:enter",_.ACTIVITYACTION,this),u.delegate("click",this.handle_data_action,s,_.ACTIVITYACTION,this),u.delegate("change",this.handle_data_action,s,_.EDITSHUFFLEQUESTIONSACTION,this)},handle_data_action:function(e){var t=e.target,i=(t=t.test("a")||t.test("input[data-action]")?t:t.ancestor(_.ACTIVITYACTION)).getData("action"),n=t.ancestor(_.ACTIVITYLI);if((t.test("a")||t.test("input[data-action]"))&&i&&n)switch(i){case"edit_section_title":this.edit_section_title(e,t,n,i);break;case"shuffle_questions":this.edit_shuffle_questions(e,t,n,i);break;case"deletesection":this.delete_section_with_confirmation(e,t,n,i)}},delete_section_with_confirmation:function(e,t,i){e.preventDefault(),require(["core/notification"],function(e){e.saveCancelPromise(M.util.get_string("confirm","moodle"),M.util.get_string("confirmremovesectionheading","quiz",i.getData("sectionname")),M.util.get_string("yes","moodle")).then(function(){var e=M.util.add_spinner(u,i.one(_.ACTIONAREA)),t={"class":"section",action:"DELETE",id:i.get("id").replace("section-","")};this.send_request(t,e,function(e){e.deleted&&window.location.reload(!0)})}.bind(this))["catch"](function(){})}.bind(this))},edit_section_title:function(e,t,o){var s,i=o.get("id").replace("section-",""),a=o.one(_.INSTANCESECTION),c=a,i={"class":"section",field:"getsectiontitle",id:i};e.preventDefault(),this.send_request(i,null,function(e){var e=e.instancesection,t=u.Node.create(''),i=u.Node.create('').set("innerHTML",M.util.get_string("edittitleinstructions","moodle")),n=u.Node.create('').setAttrs({value:e,autocomplete:"off","aria-describedby":"id_editinstructions",maxLength:"255"});t.appendChild(n),t.setData("anchor",c),a.insert(i,"before"),c.replace(t),n.focus().select(),s=n.on("blur",this.edit_section_title_cancel,this,o,!1),this.editsectionevents.push(s),s=n.on("key",this.edit_section_title_cancel,"esc",this,o,!0),this.editsectionevents.push(s),s=t.on("submit",this.edit_section_title_submit,this,o,e),this.editsectionevents.push(s)})},edit_section_title_submit:function(e,i,t){var n,o,s;e.preventDefault(),n=u.Lang.trim(i.one(_.SECTIONFORM+" "+_.SECTIONINPUT).get("value")),e=M.util.add_spinner(u, i.one(_.INSTANCESECTIONAREA)),this.edit_section_title_clear(i),null!==n&&n!==t&&(o=i.one(_.INSTANCESECTION),""===(s=n).trim()?(s=M.util.get_string("sectionnoname","quiz"),o.addClass("visually-hidden")):o.removeClass("visually-hidden"),o.setContent(s),t={"class":"section",field:"updatesectiontitle",newheading:n,id:i.get("id").replace("section-","")},this.send_request(t,e,function(e){var t;e&&(n!==e.instancesection&&(""===e.instancesection.trim()?(s=M.util.get_string("sectionnoname","quiz"),o.addClass("visually-hidden")):(s=e.instancesection,o.removeClass("visually-hidden")),o.setContent(s)),i.one(_.EDITSECTIONICON).set("title",M.util.get_string("sectionheadingedit","quiz",e.instancesection)),i.one(_.EDITSECTIONICON).set("alt",M.util.get_string("sectionheadingedit","quiz",e.instancesection)),(t=i.one(_.DELETESECTIONICON))&&(t.set("title",M.util.get_string("sectionheadingremove","quiz",e.instancesection)),t.set("alt",M.util.get_string("sectionheadingremove","quiz",e.instancesection))))}))},edit_section_title_cancel:function(e,t,i){i&&e.preventDefault(),this.edit_section_title_clear(t)},edit_section_title_clear:function(e){new u.EventHandle(this.editsectionevents).detach();var t=e.one(_.SECTIONFORM),i=e.one("#id_editinstructions");t&&t.replace(t.getData("anchor")),i&&i.remove(),u.later(100,this,function(){e.one(_.EDITSECTION).focus()}),u.one("input[name=section]")||u.one("body").append('')},edit_shuffle_questions:function(e,t,i){var n;i.one(_.EDITSHUFFLEQUESTIONSACTION).get("checked")?(n=1,i.addClass("shuffled")):(n=0,i.removeClass("shuffled")),e.preventDefault(),e={"class":"section",field:"updateshufflequestions",id:i.get("id").replace("section-",""),newshuffle:n},n=M.util.add_spinner(u,i.one(_.EDITSHUFFLEAREA)),this.send_request(e,n)}},{NAME:"mod_quiz-section-toolbox",ATTRS:{courseid:{value:0},quizid:{value:0}}}),M.mod_quiz.init_section_toolbox=function(e){return new i(e)}},"@VERSION@",{requires:["base","node","event","event-key","io","moodle-mod_quiz-quizbase","moodle-mod_quiz-util-slot","moodle-core-notification-ajaxexception"]});PK9B\#_MMPyui/build/moodle-mod_quiz-questionchooser/moodle-mod_quiz-questionchooser-min.jsnu[YUI.add("moodle-mod_quiz-questionchooser",function(t,e){var o='.menu [data-action="addquestion"]',n="div.createnewquestion",r="div.chooserdialoguebody",s="div.choosertitle",i=function(){i.superclass.constructor.apply(this,arguments)};t.extend(i,M.core.chooserdialogue,{initializer:function(){t.one("body").delegate("click",this.display_dialogue,o,this)},display_dialogue:function(e){var o,i;e.preventDefault(),o=t.one(n+" "+r),i=t.one(n+" "+s),null===this.container&&(this.setup_chooser_dialogue(o,i,{}),this.prepare_chooser()),o=t.QueryString.parse(e.currentTarget.get("search").substring(1)),i=this.container.one("form"),this.parameters_to_hidden_input(o,i,"returnurl"),this.parameters_to_hidden_input(o,i,"cmid"),this.parameters_to_hidden_input(o,i,"category"),this.parameters_to_hidden_input(o,i,"addonpage"),this.parameters_to_hidden_input(o,i,"appendqnumstring"),this.display_chooser(e)},parameters_to_hidden_input:function(e,o,i){var e=e.hasOwnProperty(i)?e[i]:"",t=o.one("input[name="+i+"]");t||(t=o.appendChild('')).set("name",i),t.set("value",e)}},{NAME:"mod_quiz-questionchooser"}),M.mod_quiz=M.mod_quiz||{},M.mod_quiz.init_questionchooser=function(){return M.mod_quiz.question_chooser=new i({}),M.mod_quiz.question_chooser}},"@VERSION@",{requires:["moodle-core-chooserdialogue","moodle-mod_quiz-util","querystring-parse"]});PK9B\; Ryui/build/moodle-mod_quiz-questionchooser/moodle-mod_quiz-questionchooser-debug.jsnu[YUI.add('moodle-mod_quiz-questionchooser', function (Y, NAME) { var CSS = { ADDNEWQUESTIONBUTTONS: '.menu [data-action="addquestion"]', CREATENEWQUESTION: 'div.createnewquestion', CHOOSERDIALOGUE: 'div.chooserdialoguebody', CHOOSERHEADER: 'div.choosertitle' }; /** * The questionchooser class is responsible for instantiating and displaying the question chooser * when viewing a quiz in editing mode. * * @class questionchooser * @constructor * @protected * @extends M.core.chooserdialogue */ var QUESTIONCHOOSER = function() { QUESTIONCHOOSER.superclass.constructor.apply(this, arguments); }; Y.extend(QUESTIONCHOOSER, M.core.chooserdialogue, { initializer: function() { Y.one('body').delegate('click', this.display_dialogue, CSS.ADDNEWQUESTIONBUTTONS, this); }, display_dialogue: function(e) { e.preventDefault(); var dialogue = Y.one(CSS.CREATENEWQUESTION + ' ' + CSS.CHOOSERDIALOGUE), header = Y.one(CSS.CREATENEWQUESTION + ' ' + CSS.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 parameters = Y.QueryString.parse(e.currentTarget.get('search').substring(1)); var form = this.container.one('form'); this.parameters_to_hidden_input(parameters, form, 'returnurl'); this.parameters_to_hidden_input(parameters, form, 'cmid'); this.parameters_to_hidden_input(parameters, form, 'category'); this.parameters_to_hidden_input(parameters, form, 'addonpage'); this.parameters_to_hidden_input(parameters, form, 'appendqnumstring'); // Display the chooser dialogue. this.display_chooser(e); }, parameters_to_hidden_input: function(parameters, form, name) { var value; if (parameters.hasOwnProperty(name)) { value = parameters[name]; } else { value = ''; } var input = form.one('input[name=' + name + ']'); if (!input) { input = form.appendChild(''); input.set('name', name); } input.set('value', value); } }, { NAME: 'mod_quiz-questionchooser' }); M.mod_quiz = M.mod_quiz || {}; M.mod_quiz.init_questionchooser = function() { M.mod_quiz.question_chooser = new QUESTIONCHOOSER({}); return M.mod_quiz.question_chooser; }; }, '@VERSION@', {"requires": ["moodle-core-chooserdialogue", "moodle-mod_quiz-util", "querystring-parse"]}); PK9B\; Lyui/build/moodle-mod_quiz-questionchooser/moodle-mod_quiz-questionchooser.jsnu[YUI.add('moodle-mod_quiz-questionchooser', function (Y, NAME) { var CSS = { ADDNEWQUESTIONBUTTONS: '.menu [data-action="addquestion"]', CREATENEWQUESTION: 'div.createnewquestion', CHOOSERDIALOGUE: 'div.chooserdialoguebody', CHOOSERHEADER: 'div.choosertitle' }; /** * The questionchooser class is responsible for instantiating and displaying the question chooser * when viewing a quiz in editing mode. * * @class questionchooser * @constructor * @protected * @extends M.core.chooserdialogue */ var QUESTIONCHOOSER = function() { QUESTIONCHOOSER.superclass.constructor.apply(this, arguments); }; Y.extend(QUESTIONCHOOSER, M.core.chooserdialogue, { initializer: function() { Y.one('body').delegate('click', this.display_dialogue, CSS.ADDNEWQUESTIONBUTTONS, this); }, display_dialogue: function(e) { e.preventDefault(); var dialogue = Y.one(CSS.CREATENEWQUESTION + ' ' + CSS.CHOOSERDIALOGUE), header = Y.one(CSS.CREATENEWQUESTION + ' ' + CSS.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 parameters = Y.QueryString.parse(e.currentTarget.get('search').substring(1)); var form = this.container.one('form'); this.parameters_to_hidden_input(parameters, form, 'returnurl'); this.parameters_to_hidden_input(parameters, form, 'cmid'); this.parameters_to_hidden_input(parameters, form, 'category'); this.parameters_to_hidden_input(parameters, form, 'addonpage'); this.parameters_to_hidden_input(parameters, form, 'appendqnumstring'); // Display the chooser dialogue. this.display_chooser(e); }, parameters_to_hidden_input: function(parameters, form, name) { var value; if (parameters.hasOwnProperty(name)) { value = parameters[name]; } else { value = ''; } var input = form.one('input[name=' + name + ']'); if (!input) { input = form.appendChild(''); input.set('name', name); } input.set('value', value); } }, { NAME: 'mod_quiz-questionchooser' }); M.mod_quiz = M.mod_quiz || {}; M.mod_quiz.init_questionchooser = function() { M.mod_quiz.question_chooser = new QUESTIONCHOOSER({}); return M.mod_quiz.question_chooser; }; }, '@VERSION@', {"requires": ["moodle-core-chooserdialogue", "moodle-mod_quiz-util", "querystring-parse"]}); PK9B\:;(wwDyui/build/moodle-mod_quiz-quizbase/moodle-mod_quiz-quizbase-debug.jsnu[YUI.add('moodle-mod_quiz-quizbase', function (Y, NAME) { /** * The quizbase class to provide shared functionality to Modules within Moodle. * * @module moodle-mod_quiz-quizbase */ var QUIZBASENAME = 'mod_quiz-quizbase'; var QUIZBASE = function() { QUIZBASE.superclass.constructor.apply(this, arguments); }; /** * The coursebase class to provide shared functionality to Modules within * Moodle. * * @class M.course.coursebase * @constructor */ Y.extend(QUIZBASE, Y.Base, { // Registered Modules registermodules: [], /** * Register a new Javascript Module * * @method register_module * @param {Object} The instantiated module to call functions on * @chainable */ register_module: function(object) { this.registermodules.push(object); return this; }, /** * Invoke the specified function in all registered modules with the given arguments * * @method invoke_function * @param {String} functionname The name of the function to call * @param {mixed} args The argument supplied to the function * @chainable */ invoke_function: function(functionname, args) { var module; for (module in this.registermodules) { if (functionname in this.registermodules[module]) { this.registermodules[module][functionname](args); } } return this; } }, { NAME: QUIZBASENAME, ATTRS: {} }); // Ensure that M.course exists and that coursebase is initialised correctly M.mod_quiz = M.mod_quiz || {}; M.mod_quiz.quizbase = M.mod_quiz.quizbase || new QUIZBASE(); // Abstract functions that needs to be defined per format (course/format/somename/format.js) M.mod_quiz.edit = M.mod_quiz.edit || {}; /** * Swap section (should be defined in format.js if requred) * * @param {YUI} Y YUI3 instance * @param {string} node1 node to swap to * @param {string} node2 node to swap with * @return {NodeList} section list */ M.mod_quiz.edit.swap_sections = function(Y, node1, node2) { var CSS = { COURSECONTENT: 'mod-quiz-edit-content', SECTIONADDMENUS: 'section_add_menus' }; var sectionlist = Y.Node.all('.' + CSS.COURSECONTENT + ' li.section'); // Swap menus. sectionlist.item(node1).one('.' + CSS.SECTIONADDMENUS).swap(sectionlist.item(node2).one('.' + CSS.SECTIONADDMENUS)); }; /** * Process sections after ajax response (should be defined in format.js) * If some response is expected, we pass it over to format, as it knows better * hot to process it. * * @param {YUI} Y YUI3 instance * @param {NodeList} list of sections * @param {array} response ajax response * @param {string} sectionfrom first affected section * @param {string} sectionto last affected section * @return void */ M.mod_quiz.edit.process_sections = function(Y, sectionlist, response, sectionfrom, sectionto) { var CSS = { SECTIONNAME: 'sectionname' }, SELECTORS = { SECTIONLEFTSIDE: '.left .section-handle .icon' }; if (response.action === 'move') { // If moving up swap around 'sectionfrom' and 'sectionto' so the that loop operates. if (sectionfrom > sectionto) { var temp = sectionto; sectionto = sectionfrom; sectionfrom = temp; } // Update titles and move icons in all affected sections. var ele, str, stridx, newstr; for (var i = sectionfrom; i <= sectionto; i++) { // Update section title. sectionlist.item(i).one('.' + CSS.SECTIONNAME).setContent(response.sectiontitles[i]); // Update move icon. ele = sectionlist.item(i).one(SELECTORS.SECTIONLEFTSIDE); str = ele.getAttribute('alt'); stridx = str.lastIndexOf(' '); newstr = str.substr(0, stridx + 1) + i; ele.setAttribute('alt', newstr); ele.setAttribute('title', newstr); // For FireFox as 'alt' is not refreshed. // Remove the current class as section has been moved. sectionlist.item(i).removeClass('current'); } // If there is a current section, apply corresponding class in order to highlight it. if (response.current !== -1) { // Add current class to the required section. sectionlist.item(response.current).addClass('current'); } } }; }, '@VERSION@', {"requires": ["base", "node"]}); PK9B\:;(ww>yui/build/moodle-mod_quiz-quizbase/moodle-mod_quiz-quizbase.jsnu[YUI.add('moodle-mod_quiz-quizbase', function (Y, NAME) { /** * The quizbase class to provide shared functionality to Modules within Moodle. * * @module moodle-mod_quiz-quizbase */ var QUIZBASENAME = 'mod_quiz-quizbase'; var QUIZBASE = function() { QUIZBASE.superclass.constructor.apply(this, arguments); }; /** * The coursebase class to provide shared functionality to Modules within * Moodle. * * @class M.course.coursebase * @constructor */ Y.extend(QUIZBASE, Y.Base, { // Registered Modules registermodules: [], /** * Register a new Javascript Module * * @method register_module * @param {Object} The instantiated module to call functions on * @chainable */ register_module: function(object) { this.registermodules.push(object); return this; }, /** * Invoke the specified function in all registered modules with the given arguments * * @method invoke_function * @param {String} functionname The name of the function to call * @param {mixed} args The argument supplied to the function * @chainable */ invoke_function: function(functionname, args) { var module; for (module in this.registermodules) { if (functionname in this.registermodules[module]) { this.registermodules[module][functionname](args); } } return this; } }, { NAME: QUIZBASENAME, ATTRS: {} }); // Ensure that M.course exists and that coursebase is initialised correctly M.mod_quiz = M.mod_quiz || {}; M.mod_quiz.quizbase = M.mod_quiz.quizbase || new QUIZBASE(); // Abstract functions that needs to be defined per format (course/format/somename/format.js) M.mod_quiz.edit = M.mod_quiz.edit || {}; /** * Swap section (should be defined in format.js if requred) * * @param {YUI} Y YUI3 instance * @param {string} node1 node to swap to * @param {string} node2 node to swap with * @return {NodeList} section list */ M.mod_quiz.edit.swap_sections = function(Y, node1, node2) { var CSS = { COURSECONTENT: 'mod-quiz-edit-content', SECTIONADDMENUS: 'section_add_menus' }; var sectionlist = Y.Node.all('.' + CSS.COURSECONTENT + ' li.section'); // Swap menus. sectionlist.item(node1).one('.' + CSS.SECTIONADDMENUS).swap(sectionlist.item(node2).one('.' + CSS.SECTIONADDMENUS)); }; /** * Process sections after ajax response (should be defined in format.js) * If some response is expected, we pass it over to format, as it knows better * hot to process it. * * @param {YUI} Y YUI3 instance * @param {NodeList} list of sections * @param {array} response ajax response * @param {string} sectionfrom first affected section * @param {string} sectionto last affected section * @return void */ M.mod_quiz.edit.process_sections = function(Y, sectionlist, response, sectionfrom, sectionto) { var CSS = { SECTIONNAME: 'sectionname' }, SELECTORS = { SECTIONLEFTSIDE: '.left .section-handle .icon' }; if (response.action === 'move') { // If moving up swap around 'sectionfrom' and 'sectionto' so the that loop operates. if (sectionfrom > sectionto) { var temp = sectionto; sectionto = sectionfrom; sectionfrom = temp; } // Update titles and move icons in all affected sections. var ele, str, stridx, newstr; for (var i = sectionfrom; i <= sectionto; i++) { // Update section title. sectionlist.item(i).one('.' + CSS.SECTIONNAME).setContent(response.sectiontitles[i]); // Update move icon. ele = sectionlist.item(i).one(SELECTORS.SECTIONLEFTSIDE); str = ele.getAttribute('alt'); stridx = str.lastIndexOf(' '); newstr = str.substr(0, stridx + 1) + i; ele.setAttribute('alt', newstr); ele.setAttribute('title', newstr); // For FireFox as 'alt' is not refreshed. // Remove the current class as section has been moved. sectionlist.item(i).removeClass('current'); } // If there is a current section, apply corresponding class in order to highlight it. if (response.current !== -1) { // Add current class to the required section. sectionlist.item(response.current).addClass('current'); } } }; }, '@VERSION@', {"requires": ["base", "node"]}); PK9B\YByui/build/moodle-mod_quiz-quizbase/moodle-mod_quiz-quizbase-min.jsnu[YUI.add("moodle-mod_quiz-quizbase",function(e,t){var i=function(){i.superclass.constructor.apply(this,arguments)};e.extend(i,e.Base,{registermodules:[],register_module:function(e){return this.registermodules.push(e),this},invoke_function:function(e,t){for(var i in this.registermodules)e in this.registermodules[i]&&this.registermodules[i][e](t);return this}},{NAME:"mod_quiz-quizbase",ATTRS:{}}),M.mod_quiz=M.mod_quiz||{},M.mod_quiz.quizbase=M.mod_quiz.quizbase||new i,M.mod_quiz.edit=M.mod_quiz.edit||{},M.mod_quiz.edit.swap_sections=function(e,t,i){var s="mod-quiz-edit-content",o="section_add_menus",s=e.Node.all("."+s+" li.section");s.item(t).one("."+o).swap(s.item(i).one("."+o))},M.mod_quiz.edit.process_sections=function(a,e,t,i,s){var o,n,u,r,d,m="sectionname",c=".left .section-handle .icon";if("move"===t.action){for(s 0) { // Remove move icons var movedown = sectionnode.one('.' + CSS.RIGHT + ' a.' + CSS.MOVEDOWN); var moveup = sectionnode.one('.' + CSS.RIGHT + ' a.' + CSS.MOVEUP); // Add dragger icon var title = M.util.get_string('movesection', 'moodle', sectionid); var cssleft = sectionnode.one('.' + CSS.LEFT); if ((movedown || moveup) && cssleft) { cssleft.setStyle('cursor', 'move'); cssleft.appendChild(this.get_drag_handle(title, CSS.SECTIONHANDLE, 'icon', true)); if (moveup) { moveup.remove(); } if (movedown) { movedown.remove(); } // This section can be moved - add the class to indicate this to Y.DD. sectionnode.addClass(CSS.SECTIONDRAGGABLE); } } }, this); }, /* * Drag-dropping related functions */ drag_start: function(e) { // Get our drag object var drag = e.target; // Creat a dummy structure of the outer elemnents for clean styles application var containernode = Y.Node.create('
        '); var sectionnode = Y.Node.create('
          '); sectionnode.setStyle('margin', 0); sectionnode.setContent(drag.get('node').get('innerHTML')); containernode.appendChild(sectionnode); drag.get('dragNode').setContent(containernode); drag.get('dragNode').addClass(CSS.COURSECONTENT); }, drag_dropmiss: function(e) { // Missed the target, but we assume the user intended to drop it // on the last last ghost node location, e.drag and e.drop should be // prepared by global_drag_dropmiss parent so simulate drop_hit(e). this.drop_hit(e); }, get_section_index: function(node) { var sectionlistselector = '.' + CSS.COURSECONTENT + ' li.section', sectionList = Y.all(sectionlistselector), nodeIndex = sectionList.indexOf(node), zeroIndex = sectionList.indexOf(Y.one('#section-0')); return (nodeIndex - zeroIndex); }, drop_hit: function(e) { var drag = e.drag; // Get references to our nodes and their IDs. var dragnode = drag.get('node'), dragnodeid = Y.Moodle.core_course.util.section.getId(dragnode), loopstart = dragnodeid, dropnodeindex = this.get_section_index(dragnode), loopend = dropnodeindex; if (dragnodeid === dropnodeindex) { Y.log("Skipping move - same location moving " + dragnodeid + " to " + dropnodeindex, 'debug', 'moodle-mod_quiz-dragdrop'); return; } Y.log("Moving from position " + dragnodeid + " to position " + dropnodeindex, 'debug', 'moodle-mod_quiz-dragdrop'); if (loopstart > loopend) { // If we're going up, we need to swap the loop order // because loops can't go backwards. loopstart = dropnodeindex; loopend = dragnodeid; } // Get the list of nodes. drag.get('dragNode').removeClass(CSS.COURSECONTENT); var sectionlist = Y.Node.all(this.sectionlistselector); // Add a lightbox if it's not there. var lightbox = M.util.add_lightbox(Y, dragnode); // Handle any variables which we must pass via AJAX. var params = {}, pageparams = this.get('config').pageparams, varname; for (varname in pageparams) { if (!pageparams.hasOwnProperty(varname)) { continue; } params[varname] = pageparams[varname]; } // Prepare request parameters params.sesskey = M.cfg.sesskey; params.courseid = this.get('courseid'); params.quizid = this.get('quizid'); params['class'] = 'section'; params.field = 'move'; params.id = dragnodeid; params.value = dropnodeindex; // Perform the AJAX request. var uri = M.cfg.wwwroot + this.get('ajaxurl'); Y.io(uri, { method: 'POST', data: params, on: { start: function() { lightbox.show(); }, success: function(tid, response) { // Update section titles, we can't simply swap them as // they might have custom title try { var responsetext = Y.JSON.parse(response.responseText); if (responsetext.error) { new M.core.ajaxException(responsetext); } M.mod_quiz.edit.process_sections(Y, sectionlist, responsetext, loopstart, loopend); } catch (e) { // Ignore. } // Update all of the section IDs - first unset them, then set them // to avoid duplicates in the DOM. var index; // Classic bubble sort algorithm is applied to the section // nodes between original drag node location and the new one. var swapped = false; do { swapped = false; for (index = loopstart; index <= loopend; index++) { if (Y.Moodle.core_course.util.section.getId(sectionlist.item(index - 1)) > Y.Moodle.core_course.util.section.getId(sectionlist.item(index))) { Y.log("Swapping " + Y.Moodle.core_course.util.section.getId(sectionlist.item(index - 1)) + " with " + Y.Moodle.core_course.util.section.getId(sectionlist.item(index)), "debug", "moodle-mod_quiz-dragdrop"); // Swap section id. var sectionid = sectionlist.item(index - 1).get('id'); sectionlist.item(index - 1).set('id', sectionlist.item(index).get('id')); sectionlist.item(index).set('id', sectionid); // See what format needs to swap. M.mod_quiz.edit.swap_sections(Y, index - 1, index); // Update flag. swapped = true; } } loopend = loopend - 1; } while (swapped); window.setTimeout(function() { lightbox.hide(); }, 250); }, failure: function(tid, response) { this.ajax_failure(response); lightbox.hide(); } }, context: this }); } }, { NAME: 'mod_quiz-dragdrop-section', ATTRS: { courseid: { value: null }, quizid: { value: null }, ajaxurl: { value: 0 }, config: { value: 0 } } }); M.mod_quiz = M.mod_quiz || {}; M.mod_quiz.init_section_dragdrop = function(params) { new DRAGSECTION(params); }; /** * Resource drag and drop. * * @class M.course.dragdrop.resource * @constructor * @extends M.core.dragdrop */ var DRAGRESOURCE = function() { DRAGRESOURCE.superclass.constructor.apply(this, arguments); }; Y.extend(DRAGRESOURCE, M.core.dragdrop, { initializer: function() { // Set group for parent class this.groups = ['resource']; this.samenodeclass = CSS.ACTIVITY; this.parentnodeclass = CSS.SECTION; this.samenodelabel = { identifier: 'dragtoafter', component: 'quiz' }; this.parentnodelabel = { identifier: 'dragtostart', component: 'quiz' }; // Go through all sections this.setup_for_section(); // Initialise drag & drop for all resources/activities var nodeselector = 'li.' + CSS.ACTIVITY; var del = new Y.DD.Delegate({ container: '.' + CSS.COURSECONTENT, nodes: nodeselector, target: true, handles: ['.' + CSS.EDITINGMOVE], dragConfig: {groups: this.groups} }); del.dd.plug(Y.Plugin.DDProxy, { // Don't move the node at the end of the drag moveOnEnd: false, cloneNode: true }); del.dd.plug(Y.Plugin.DDConstrained, { // Keep it inside the .mod-quiz-edit-content constrain: '#' + CSS.SLOTS }); del.dd.plug(Y.Plugin.DDWinScroll); M.mod_quiz.quizbase.register_module(this); M.mod_quiz.dragres = this; }, /** * Apply dragdrop features to the specified selector or node that refers to section(s) * * @method setup_for_section * @param {String} baseselector The CSS selector or node to limit scope to */ setup_for_section: function() { Y.Node.all('.mod-quiz-edit-content ul.slots ul.section').each(function(resources) { resources.setAttribute('data-draggroups', this.groups.join(' ')); // Define empty ul as droptarget, so that item could be moved to empty list new Y.DD.Drop({ node: resources, groups: this.groups, padding: '20 0 20 0' }); // Initialise each resource/activity in this section this.setup_for_resource('li.activity'); }, this); }, /** * Apply dragdrop features to the specified selector or node that refers to resource(s) * * @method setup_for_resource * @param {String} baseselector The CSS selector or node to limit scope to */ setup_for_resource: function(baseselector) { Y.Node.all(baseselector).each(function(resourcesnode) { // Replace move icons var move = resourcesnode.one('a.' + CSS.EDITINGMOVE); if (move) { var resourcedraghandle = this.get_drag_handle(M.util.get_string('move', 'moodle'), CSS.EDITINGMOVE, CSS.ICONCLASS, true); move.replace(resourcedraghandle); } }, this); }, drag_start: function(e) { // Get our drag object var drag = e.target; drag.get('dragNode').setContent(drag.get('node').get('innerHTML')); drag.get('dragNode').all('.icon').setStyle('vertical-align', 'baseline'); }, drag_dropmiss: function(e) { // Missed the target, but we assume the user intended to drop it // on the last ghost node location, e.drag and e.drop should be // prepared by global_drag_dropmiss parent so simulate drop_hit(e). this.drop_hit(e); }, drop_hit: function(e) { var drag = e.drag; // Get a reference to our drag node var dragnode = drag.get('node'); // Add spinner if it not there var actionarea = dragnode.one(CSS.ACTIONAREA); var spinner = M.util.add_spinner(Y, actionarea); var params = {}; // Handle any variables which we must pass back through to var pageparams = this.get('config').pageparams; var varname; for (varname in pageparams) { params[varname] = pageparams[varname]; } // Prepare request parameters params.sesskey = M.cfg.sesskey; params.courseid = this.get('courseid'); params.quizid = this.get('quizid'); params['class'] = 'resource'; params.field = 'move'; params.id = Number(Y.Moodle.mod_quiz.util.slot.getId(dragnode)); params.sectionId = Y.Moodle.core_course.util.section.getId(dragnode.ancestor('li.section', true)); var previousslot = dragnode.previous(SELECTOR.SLOT); if (previousslot) { params.previousid = Number(Y.Moodle.mod_quiz.util.slot.getId(previousslot)); } var previouspage = dragnode.previous(SELECTOR.PAGE); if (previouspage) { params.page = Number(Y.Moodle.mod_quiz.util.page.getId(previouspage)); } // Do AJAX request var uri = M.cfg.wwwroot + this.get('ajaxurl'); Y.io(uri, { method: 'POST', data: params, on: { start: function() { this.lock_drag_handle(drag, CSS.EDITINGMOVE); spinner.show(); }, success: function(tid, response) { var responsetext = Y.JSON.parse(response.responseText); var params = {element: dragnode, visible: responsetext.visible}; M.mod_quiz.quizbase.invoke_function('set_visibility_resource_ui', params); this.unlock_drag_handle(drag, CSS.EDITINGMOVE); window.setTimeout(function() { spinner.hide(); }, 250); M.mod_quiz.resource_toolbox.reorganise_edit_page(); }, failure: function(tid, response) { this.ajax_failure(response); this.unlock_drag_handle(drag, CSS.SECTIONHANDLE); spinner.hide(); window.location.reload(true); } }, context: this }); }, global_drop_over: function(e) { // Overriding parent method so we can stop the slots being dragged before the first page node. // Check that drop object belong to correct group. if (!e.drop || !e.drop.inGroup(this.groups)) { return; } // Get a reference to our drag and drop nodes. var drag = e.drag.get('node'), drop = e.drop.get('node'); // Save last drop target for the case of missed target processing. this.lastdroptarget = e.drop; // Are we dropping within the same parent node? if (drop.hasClass(this.samenodeclass)) { var where; if (this.goingup) { where = "before"; } else { where = "after"; } drop.insert(drag, where); } else if ((drop.hasClass(this.parentnodeclass) || drop.test('[data-droptarget="1"]')) && !drop.contains(drag)) { // We are dropping on parent node and it is empty if (this.goingup) { drop.append(drag); } else { drop.prepend(drag); } } this.drop_over(e); } }, { NAME: 'mod_quiz-dragdrop-resource', ATTRS: { courseid: { value: null }, quizid: { value: null }, ajaxurl: { value: 0 }, config: { value: 0 } } }); M.mod_quiz = M.mod_quiz || {}; M.mod_quiz.init_resource_dragdrop = function(params) { new DRAGRESOURCE(params); }; }, '@VERSION@', { "requires": [ "base", "node", "io", "dom", "dd", "dd-scroll", "moodle-core-dragdrop", "moodle-core-notification", "moodle-mod_quiz-quizbase", "moodle-mod_quiz-util-base", "moodle-mod_quiz-util-page", "moodle-mod_quiz-util-slot", "moodle-course-util" ] }); PK9B\(jGG>yui/build/moodle-mod_quiz-dragdrop/moodle-mod_quiz-dragdrop.jsnu[YUI.add('moodle-mod_quiz-dragdrop', function (Y, NAME) { /* eslint-disable no-unused-vars */ /** * Drag and Drop for Quiz sections and slots. * * @module moodle-mod-quiz-dragdrop */ var CSS = { ACTIONAREA: '.actions', ACTIVITY: 'activity', ACTIVITYINSTANCE: 'activityinstance', CONTENT: 'content', COURSECONTENT: 'mod-quiz-edit-content', EDITINGMOVE: 'editing_move', ICONCLASS: 'iconsmall', JUMPMENU: 'jumpmenu', LEFT: 'left', LIGHTBOX: 'lightbox', MOVEDOWN: 'movedown', MOVEUP: 'moveup', PAGE: 'page', PAGECONTENT: 'page-content', RIGHT: 'right', SECTION: 'slots', SECTIONADDMENUS: 'section_add_menus', SECTIONHANDLE: 'section-handle', SLOTS: 'slots', SUMMARY: 'summary', SECTIONDRAGGABLE: 'sectiondraggable' }, // The CSS selectors we use. SELECTOR = { PAGE: 'li.page', SLOT: 'li.slot' }; /** * Section drag and drop. * * @class M.mod_quiz.dragdrop.section * @constructor * @extends M.core.dragdrop */ var DRAGSECTION = function() { DRAGSECTION.superclass.constructor.apply(this, arguments); }; Y.extend(DRAGSECTION, M.core.dragdrop, { sectionlistselector: null, initializer: function() { // Set group for parent class this.groups = [CSS.SECTIONDRAGGABLE]; this.samenodeclass = 'section'; this.parentnodeclass = 'slots'; // Check if we are in single section mode if (Y.Node.one('.' + CSS.JUMPMENU)) { return false; } // Initialise sections dragging this.sectionlistselector = 'li.section'; if (this.sectionlistselector) { this.sectionlistselector = '.' + CSS.COURSECONTENT + ' ' + this.sectionlistselector; this.setup_for_section(this.sectionlistselector); // Make each li element in the lists of sections draggable var del = new Y.DD.Delegate({ container: '.' + CSS.COURSECONTENT, nodes: '.' + CSS.SECTIONDRAGGABLE, target: true, handles: ['.' + CSS.LEFT], dragConfig: {groups: this.groups} }); del.dd.plug(Y.Plugin.DDProxy, { // Don't move the node at the end of the drag moveOnEnd: false }); del.dd.plug(Y.Plugin.DDConstrained, { // Keep it inside the .mod-quiz-edit-content constrain: '#' + CSS.PAGECONTENT, stickY: true }); del.dd.plug(Y.Plugin.DDWinScroll); } }, /** * Apply dragdrop features to the specified selector or node that refers to section(s) * * @method setup_for_section * @param {String} baseselector The CSS selector or node to limit scope to */ setup_for_section: function(baseselector) { Y.Node.all(baseselector).each(function(sectionnode) { // Determine the section ID var sectionid = Y.Moodle.core_course.util.section.getId(sectionnode); // We skip the top section as it is not draggable if (sectionid > 0) { // Remove move icons var movedown = sectionnode.one('.' + CSS.RIGHT + ' a.' + CSS.MOVEDOWN); var moveup = sectionnode.one('.' + CSS.RIGHT + ' a.' + CSS.MOVEUP); // Add dragger icon var title = M.util.get_string('movesection', 'moodle', sectionid); var cssleft = sectionnode.one('.' + CSS.LEFT); if ((movedown || moveup) && cssleft) { cssleft.setStyle('cursor', 'move'); cssleft.appendChild(this.get_drag_handle(title, CSS.SECTIONHANDLE, 'icon', true)); if (moveup) { moveup.remove(); } if (movedown) { movedown.remove(); } // This section can be moved - add the class to indicate this to Y.DD. sectionnode.addClass(CSS.SECTIONDRAGGABLE); } } }, this); }, /* * Drag-dropping related functions */ drag_start: function(e) { // Get our drag object var drag = e.target; // Creat a dummy structure of the outer elemnents for clean styles application var containernode = Y.Node.create('
            '); var sectionnode = Y.Node.create('
              '); sectionnode.setStyle('margin', 0); sectionnode.setContent(drag.get('node').get('innerHTML')); containernode.appendChild(sectionnode); drag.get('dragNode').setContent(containernode); drag.get('dragNode').addClass(CSS.COURSECONTENT); }, drag_dropmiss: function(e) { // Missed the target, but we assume the user intended to drop it // on the last last ghost node location, e.drag and e.drop should be // prepared by global_drag_dropmiss parent so simulate drop_hit(e). this.drop_hit(e); }, get_section_index: function(node) { var sectionlistselector = '.' + CSS.COURSECONTENT + ' li.section', sectionList = Y.all(sectionlistselector), nodeIndex = sectionList.indexOf(node), zeroIndex = sectionList.indexOf(Y.one('#section-0')); return (nodeIndex - zeroIndex); }, drop_hit: function(e) { var drag = e.drag; // Get references to our nodes and their IDs. var dragnode = drag.get('node'), dragnodeid = Y.Moodle.core_course.util.section.getId(dragnode), loopstart = dragnodeid, dropnodeindex = this.get_section_index(dragnode), loopend = dropnodeindex; if (dragnodeid === dropnodeindex) { return; } if (loopstart > loopend) { // If we're going up, we need to swap the loop order // because loops can't go backwards. loopstart = dropnodeindex; loopend = dragnodeid; } // Get the list of nodes. drag.get('dragNode').removeClass(CSS.COURSECONTENT); var sectionlist = Y.Node.all(this.sectionlistselector); // Add a lightbox if it's not there. var lightbox = M.util.add_lightbox(Y, dragnode); // Handle any variables which we must pass via AJAX. var params = {}, pageparams = this.get('config').pageparams, varname; for (varname in pageparams) { if (!pageparams.hasOwnProperty(varname)) { continue; } params[varname] = pageparams[varname]; } // Prepare request parameters params.sesskey = M.cfg.sesskey; params.courseid = this.get('courseid'); params.quizid = this.get('quizid'); params['class'] = 'section'; params.field = 'move'; params.id = dragnodeid; params.value = dropnodeindex; // Perform the AJAX request. var uri = M.cfg.wwwroot + this.get('ajaxurl'); Y.io(uri, { method: 'POST', data: params, on: { start: function() { lightbox.show(); }, success: function(tid, response) { // Update section titles, we can't simply swap them as // they might have custom title try { var responsetext = Y.JSON.parse(response.responseText); if (responsetext.error) { new M.core.ajaxException(responsetext); } M.mod_quiz.edit.process_sections(Y, sectionlist, responsetext, loopstart, loopend); } catch (e) { // Ignore. } // Update all of the section IDs - first unset them, then set them // to avoid duplicates in the DOM. var index; // Classic bubble sort algorithm is applied to the section // nodes between original drag node location and the new one. var swapped = false; do { swapped = false; for (index = loopstart; index <= loopend; index++) { if (Y.Moodle.core_course.util.section.getId(sectionlist.item(index - 1)) > Y.Moodle.core_course.util.section.getId(sectionlist.item(index))) { // Swap section id. var sectionid = sectionlist.item(index - 1).get('id'); sectionlist.item(index - 1).set('id', sectionlist.item(index).get('id')); sectionlist.item(index).set('id', sectionid); // See what format needs to swap. M.mod_quiz.edit.swap_sections(Y, index - 1, index); // Update flag. swapped = true; } } loopend = loopend - 1; } while (swapped); window.setTimeout(function() { lightbox.hide(); }, 250); }, failure: function(tid, response) { this.ajax_failure(response); lightbox.hide(); } }, context: this }); } }, { NAME: 'mod_quiz-dragdrop-section', ATTRS: { courseid: { value: null }, quizid: { value: null }, ajaxurl: { value: 0 }, config: { value: 0 } } }); M.mod_quiz = M.mod_quiz || {}; M.mod_quiz.init_section_dragdrop = function(params) { new DRAGSECTION(params); }; /** * Resource drag and drop. * * @class M.course.dragdrop.resource * @constructor * @extends M.core.dragdrop */ var DRAGRESOURCE = function() { DRAGRESOURCE.superclass.constructor.apply(this, arguments); }; Y.extend(DRAGRESOURCE, M.core.dragdrop, { initializer: function() { // Set group for parent class this.groups = ['resource']; this.samenodeclass = CSS.ACTIVITY; this.parentnodeclass = CSS.SECTION; this.samenodelabel = { identifier: 'dragtoafter', component: 'quiz' }; this.parentnodelabel = { identifier: 'dragtostart', component: 'quiz' }; // Go through all sections this.setup_for_section(); // Initialise drag & drop for all resources/activities var nodeselector = 'li.' + CSS.ACTIVITY; var del = new Y.DD.Delegate({ container: '.' + CSS.COURSECONTENT, nodes: nodeselector, target: true, handles: ['.' + CSS.EDITINGMOVE], dragConfig: {groups: this.groups} }); del.dd.plug(Y.Plugin.DDProxy, { // Don't move the node at the end of the drag moveOnEnd: false, cloneNode: true }); del.dd.plug(Y.Plugin.DDConstrained, { // Keep it inside the .mod-quiz-edit-content constrain: '#' + CSS.SLOTS }); del.dd.plug(Y.Plugin.DDWinScroll); M.mod_quiz.quizbase.register_module(this); M.mod_quiz.dragres = this; }, /** * Apply dragdrop features to the specified selector or node that refers to section(s) * * @method setup_for_section * @param {String} baseselector The CSS selector or node to limit scope to */ setup_for_section: function() { Y.Node.all('.mod-quiz-edit-content ul.slots ul.section').each(function(resources) { resources.setAttribute('data-draggroups', this.groups.join(' ')); // Define empty ul as droptarget, so that item could be moved to empty list new Y.DD.Drop({ node: resources, groups: this.groups, padding: '20 0 20 0' }); // Initialise each resource/activity in this section this.setup_for_resource('li.activity'); }, this); }, /** * Apply dragdrop features to the specified selector or node that refers to resource(s) * * @method setup_for_resource * @param {String} baseselector The CSS selector or node to limit scope to */ setup_for_resource: function(baseselector) { Y.Node.all(baseselector).each(function(resourcesnode) { // Replace move icons var move = resourcesnode.one('a.' + CSS.EDITINGMOVE); if (move) { var resourcedraghandle = this.get_drag_handle(M.util.get_string('move', 'moodle'), CSS.EDITINGMOVE, CSS.ICONCLASS, true); move.replace(resourcedraghandle); } }, this); }, drag_start: function(e) { // Get our drag object var drag = e.target; drag.get('dragNode').setContent(drag.get('node').get('innerHTML')); drag.get('dragNode').all('.icon').setStyle('vertical-align', 'baseline'); }, drag_dropmiss: function(e) { // Missed the target, but we assume the user intended to drop it // on the last ghost node location, e.drag and e.drop should be // prepared by global_drag_dropmiss parent so simulate drop_hit(e). this.drop_hit(e); }, drop_hit: function(e) { var drag = e.drag; // Get a reference to our drag node var dragnode = drag.get('node'); // Add spinner if it not there var actionarea = dragnode.one(CSS.ACTIONAREA); var spinner = M.util.add_spinner(Y, actionarea); var params = {}; // Handle any variables which we must pass back through to var pageparams = this.get('config').pageparams; var varname; for (varname in pageparams) { params[varname] = pageparams[varname]; } // Prepare request parameters params.sesskey = M.cfg.sesskey; params.courseid = this.get('courseid'); params.quizid = this.get('quizid'); params['class'] = 'resource'; params.field = 'move'; params.id = Number(Y.Moodle.mod_quiz.util.slot.getId(dragnode)); params.sectionId = Y.Moodle.core_course.util.section.getId(dragnode.ancestor('li.section', true)); var previousslot = dragnode.previous(SELECTOR.SLOT); if (previousslot) { params.previousid = Number(Y.Moodle.mod_quiz.util.slot.getId(previousslot)); } var previouspage = dragnode.previous(SELECTOR.PAGE); if (previouspage) { params.page = Number(Y.Moodle.mod_quiz.util.page.getId(previouspage)); } // Do AJAX request var uri = M.cfg.wwwroot + this.get('ajaxurl'); Y.io(uri, { method: 'POST', data: params, on: { start: function() { this.lock_drag_handle(drag, CSS.EDITINGMOVE); spinner.show(); }, success: function(tid, response) { var responsetext = Y.JSON.parse(response.responseText); var params = {element: dragnode, visible: responsetext.visible}; M.mod_quiz.quizbase.invoke_function('set_visibility_resource_ui', params); this.unlock_drag_handle(drag, CSS.EDITINGMOVE); window.setTimeout(function() { spinner.hide(); }, 250); M.mod_quiz.resource_toolbox.reorganise_edit_page(); }, failure: function(tid, response) { this.ajax_failure(response); this.unlock_drag_handle(drag, CSS.SECTIONHANDLE); spinner.hide(); window.location.reload(true); } }, context: this }); }, global_drop_over: function(e) { // Overriding parent method so we can stop the slots being dragged before the first page node. // Check that drop object belong to correct group. if (!e.drop || !e.drop.inGroup(this.groups)) { return; } // Get a reference to our drag and drop nodes. var drag = e.drag.get('node'), drop = e.drop.get('node'); // Save last drop target for the case of missed target processing. this.lastdroptarget = e.drop; // Are we dropping within the same parent node? if (drop.hasClass(this.samenodeclass)) { var where; if (this.goingup) { where = "before"; } else { where = "after"; } drop.insert(drag, where); } else if ((drop.hasClass(this.parentnodeclass) || drop.test('[data-droptarget="1"]')) && !drop.contains(drag)) { // We are dropping on parent node and it is empty if (this.goingup) { drop.append(drag); } else { drop.prepend(drag); } } this.drop_over(e); } }, { NAME: 'mod_quiz-dragdrop-resource', ATTRS: { courseid: { value: null }, quizid: { value: null }, ajaxurl: { value: 0 }, config: { value: 0 } } }); M.mod_quiz = M.mod_quiz || {}; M.mod_quiz.init_resource_dragdrop = function(params) { new DRAGRESOURCE(params); }; }, '@VERSION@', { "requires": [ "base", "node", "io", "dom", "dd", "dd-scroll", "moodle-core-dragdrop", "moodle-core-notification", "moodle-mod_quiz-quizbase", "moodle-mod_quiz-util-base", "moodle-mod_quiz-util-page", "moodle-mod_quiz-util-slot", "moodle-course-util" ] }); PK9B\JByui/build/moodle-mod_quiz-dragdrop/moodle-mod_quiz-dragdrop-min.jsnu[YUI.add("moodle-mod_quiz-dragdrop",function(g,e){var o,u=".actions",t="activity",p="mod-quiz-edit-content",r="editing_move",s="iconsmall",c="jumpmenu",n="left",h="movedown",_="moveup",m="page-content",d="right",f="slots",a="section-handle",v="slots",l="sectiondraggable",z="li.page",q="li.slot",i=function(){i.superclass.constructor.apply(this,arguments)};g.extend(i,M.core.dragdrop,{sectionlistselector:null,initializer:function(){if(this.groups=[l],this.samenodeclass="section",this.parentnodeclass="slots",g.Node.one("."+c))return!1;var e;this.sectionlistselector="li.section",this.sectionlistselector&&(this.sectionlistselector="."+p+" "+this.sectionlistselector,this.setup_for_section(this.sectionlistselector),(e=new g.DD.Delegate({container:"."+p,nodes:"."+l,target:!0,handles:["."+n],dragConfig:{groups:this.groups}})).dd.plug(g.Plugin.DDProxy,{moveOnEnd:!1}),e.dd.plug(g.Plugin.DDConstrained,{constrain:"#"+m,stickY:!0}),e.dd.plug(g.Plugin.DDWinScroll))},setup_for_section:function(e){g.Node.all(e).each(function(e){var o,t,i,s=g.Moodle.core_course.util.section.getId(e);0'),t=g.Node.create('
                ');t.setStyle("margin",0),t.setContent(e.get("node").get("innerHTML")),o.appendChild(t),e.get("dragNode").setContent(o),e.get("dragNode").addClass(p)},drag_dropmiss:function(e){this.drop_hit(e)},get_section_index:function(e){var o="."+p+" li.section",o=g.all(o);return o.indexOf(e)-o.indexOf(g.one("#section-0"))},drop_hit:function(d){var r,a,e,n,o,t=d.drag,c=t.get("node"),i=g.Moodle.core_course.util.section.getId(c),l=i,s=this.get_section_index(c),u=s;if(i!==s){for(o in ug.Moodle.core_course.util.section.getId(r.item(i))&&(n=r.item(i-1).get("id"),r.item(i-1).set("id",r.item(i).get("id")),r.item(i).set("id",n),M.mod_quiz.edit.swap_sections(g,i-1,i),s=!0)}while(u-=1,s);window.setTimeout(function(){a.hide()},250)},failure:function(e,o){this.ajax_failure(o),a.hide()}},context:this})}}},{NAME:"mod_quiz-dragdrop-section",ATTRS:{courseid:{value:null},quizid:{value:null},ajaxurl:{value:0},config:{value:0}}}),M.mod_quiz=M.mod_quiz||{},M.mod_quiz.init_section_dragdrop=function(e){new i(e)},g.extend(o=function(){o.superclass.constructor.apply(this,arguments)},M.core.dragdrop,{initializer:function(){var e;this.groups=["resource"],this.samenodeclass=t,this.parentnodeclass=f,this.samenodelabel={identifier:"dragtoafter",component:"quiz"},this.parentnodelabel={identifier:"dragtostart",component:"quiz"},this.setup_for_section(),e="li."+t,(e=new g.DD.Delegate({container:"."+p,nodes:e,target:!0,handles:["."+r],dragConfig:{groups:this.groups}})).dd.plug(g.Plugin.DDProxy,{moveOnEnd:!1,cloneNode:!0}),e.dd.plug(g.Plugin.DDConstrained,{constrain:"#"+v}),e.dd.plug(g.Plugin.DDWinScroll),M.mod_quiz.quizbase.register_module(this),M.mod_quiz.dragres=this},setup_for_section:function(){g.Node.all(".mod-quiz-edit-content ul.slots ul.section").each(function(e){e.setAttribute("data-draggroups",this.groups.join(" ")),new g.DD.Drop({node:e,groups:this.groups,padding:"20 0 20 0"}),this.setup_for_resource("li.activity")},this)},setup_for_resource:function(e){g.Node.all(e).each(function(e){var o,e=e.one("a."+r);e&&(o=this.get_drag_handle(M.util.get_string("move","moodle"),r,s,!0),e.replace(o))},this)},drag_start:function(e){e=e.target;e.get("dragNode").setContent(e.get("node").get("innerHTML")),e.get("dragNode").all(".icon").setStyle("vertical-align","baseline")},drag_dropmiss:function(e){this.drop_hit(e)},drop_hit:function(e){var o,t=e.drag,i=t.get("node"),e=i.one(u),s=M.util.add_spinner(g,e),n={},d=this.get("config").pageparams;for(o in d)n[o]=d[o];n.sesskey=M.cfg.sesskey,n.courseid=this.get("courseid"),n.quizid=this.get("quizid"),n["class"]="resource",n.field="move",n.id=Number(g.Moodle.mod_quiz.util.slot.getId(i)),n.sectionId=g.Moodle.core_course.util.section.getId(i.ancestor("li.section",!0)),(e=i.previous(q))&&(n.previousid=Number(g.Moodle.mod_quiz.util.slot.getId(e))),(e=i.previous(z))&&(n.page=Number(g.Moodle.mod_quiz.util.page.getId(e))),e=M.cfg.wwwroot+this.get("ajaxurl"),g.io(e,{method:"POST",data:n,on:{start:function(){this.lock_drag_handle(t,r),s.show()},success:function(e,o){o=g.JSON.parse(o.responseText),o={element:i,visible:o.visible};M.mod_quiz.quizbase.invoke_function("set_visibility_resource_ui",o),this.unlock_drag_handle(t,r),window.setTimeout(function(){s.hide()},250),M.mod_quiz.resource_toolbox.reorganise_edit_page()},failure:function(e,o){this.ajax_failure(o),this.unlock_drag_handle(t,a),s.hide(),window.location.reload(!0)}},context:this})},global_drop_over:function(e){var o,t,i;e.drop&&e.drop.inGroup(this.groups)&&(o=e.drag.get("node"),t=e.drop.get("node"),this.lastdroptarget=e.drop,t.hasClass(this.samenodeclass)?(i=this.goingup?"before":"after",t.insert(o,i)):!t.hasClass(this.parentnodeclass)&&!t.test('[data-droptarget="1"]')||t.contains(o)||(this.goingup?t.append(o):t.prepend(o)),this.drop_over(e))}},{NAME:"mod_quiz-dragdrop-resource",ATTRS:{courseid:{value:null},quizid:{value:null},ajaxurl:{value:0},config:{value:0}}}), M.mod_quiz=M.mod_quiz||{},M.mod_quiz.init_resource_dragdrop=function(e){new o(e)}},"@VERSION@",{requires:["base","node","io","dom","dd","dd-scroll","moodle-core-dragdrop","moodle-core-notification","moodle-mod_quiz-quizbase","moodle-mod_quiz-util-base","moodle-mod_quiz-util-page","moodle-mod_quiz-util-slot","moodle-course-util"]});PK9B\r @yui/build/moodle-mod_quiz-modform/moodle-mod_quiz-modform-min.jsnu[YUI.add("moodle-mod_quiz-modform",function(e,i){var t=function(){t.superclass.constructor.apply(this,arguments)};e.extend(t,e.Base,{repaginateCheckbox:null,qppSelect:null,qppInitialValue:0,initializer:function(){this.repaginateCheckbox=e.one("#id_repaginatenow"),this.repaginateCheckbox&&(this.qppSelect=e.one("#id_questionsperpage"),this.qppInitialValue=this.qppSelect.get("value"),this.qppSelect.on("change",this.qppChanged,this))},qppChanged:function(){e.later(50,this,function(){this.repaginateCheckbox.get("disabled")||this.repaginateCheckbox.set("checked",this.qppSelect.get("value")!==this.qppInitialValue)})}}),M.mod_quiz=M.mod_quiz||{},M.mod_quiz.modform=M.mod_quiz.modform||new t,M.mod_quiz.modform.init=function(){return new t}},"@VERSION@",{requires:["base","node","event"]});PK9B\02Byui/build/moodle-mod_quiz-modform/moodle-mod_quiz-modform-debug.jsnu[YUI.add('moodle-mod_quiz-modform', function (Y, NAME) { /** * The modform class has all the JavaScript specific to mod/quiz/mod_form.php. * * @module moodle-mod_quiz-modform */ var MODFORM = function() { MODFORM.superclass.constructor.apply(this, arguments); }; /** * The coursebase class to provide shared functionality to Modules within * Moodle. * * @class M.course.coursebase * @constructor */ Y.extend(MODFORM, Y.Base, { repaginateCheckbox: null, qppSelect: null, qppInitialValue: 0, initializer: function() { this.repaginateCheckbox = Y.one('#id_repaginatenow'); if (!this.repaginateCheckbox) { // The checkbox only appears when editing an existing quiz. return; } this.qppSelect = Y.one('#id_questionsperpage'); this.qppInitialValue = this.qppSelect.get('value'); this.qppSelect.on('change', this.qppChanged, this); }, qppChanged: function() { Y.later(50, this, function() { if (!this.repaginateCheckbox.get('disabled')) { this.repaginateCheckbox.set('checked', this.qppSelect.get('value') !== this.qppInitialValue); } }); } }); // Ensure that M.mod_quiz exists and that coursebase is initialised correctly M.mod_quiz = M.mod_quiz || {}; M.mod_quiz.modform = M.mod_quiz.modform || new MODFORM(); M.mod_quiz.modform.init = function() { return new MODFORM(); }; }, '@VERSION@', {"requires": ["base", "node", "event"]}); PK9B\02<yui/build/moodle-mod_quiz-modform/moodle-mod_quiz-modform.jsnu[YUI.add('moodle-mod_quiz-modform', function (Y, NAME) { /** * The modform class has all the JavaScript specific to mod/quiz/mod_form.php. * * @module moodle-mod_quiz-modform */ var MODFORM = function() { MODFORM.superclass.constructor.apply(this, arguments); }; /** * The coursebase class to provide shared functionality to Modules within * Moodle. * * @class M.course.coursebase * @constructor */ Y.extend(MODFORM, Y.Base, { repaginateCheckbox: null, qppSelect: null, qppInitialValue: 0, initializer: function() { this.repaginateCheckbox = Y.one('#id_repaginatenow'); if (!this.repaginateCheckbox) { // The checkbox only appears when editing an existing quiz. return; } this.qppSelect = Y.one('#id_questionsperpage'); this.qppInitialValue = this.qppSelect.get('value'); this.qppSelect.on('change', this.qppChanged, this); }, qppChanged: function() { Y.later(50, this, function() { if (!this.repaginateCheckbox.get('disabled')) { this.repaginateCheckbox.set('checked', this.qppSelect.get('value') !== this.qppInitialValue); } }); } }); // Ensure that M.mod_quiz exists and that coursebase is initialised correctly M.mod_quiz = M.mod_quiz || {}; M.mod_quiz.modform = M.mod_quiz.modform || new MODFORM(); M.mod_quiz.modform.init = function() { return new MODFORM(); }; }, '@VERSION@', {"requires": ["base", "node", "event"]}); PK9B\?Dyui/build/moodle-mod_quiz-util-slot/moodle-mod_quiz-util-slot-min.jsnu[YUI.add("moodle-mod_quiz-util-slot",function(u,e){u.namespace("Moodle.mod_quiz.util.slot"),u.Moodle.mod_quiz.util.slot={CSS:{SLOT:"slot",QUESTIONTYPEDESCRIPTION:"qtype_description",CANNOT_DEPEND:"question_dependency_cannot_depend"},CONSTANTS:{SLOTIDPREFIX:"slot-",QUESTION:M.util.get_string("question","moodle")},SELECTORS:{SLOT:"li.slot",INSTANCENAME:".instancename",NUMBER:"span.slotnumber",PAGECONTENT:"div#page-content",PAGEBREAK:"span.page_split_join_wrapper",ICON:".icon",QUESTIONTYPEDESCRIPTION:".qtype_description",SECTIONUL:"ul.section",DEPENDENCY_WRAPPER:".question_dependency_wrapper",DEPENDENCY_LINK:".question_dependency_wrapper .cm-edit-action",DEPENDENCY_ICON:".question_dependency_wrapper .icon"},getSlotFromComponent:function(e){return u.one(e).ancestor(this.SELECTORS.SLOT,!0)},getId:function(e){e=e.get("id").replace(this.CONSTANTS.SLOTIDPREFIX,"");return!("number"!=typeof(e=parseInt(e,10))||!isFinite(e))&&e},getName:function(e){e=e.one(this.SELECTORS.INSTANCENAME);return e?e.get("firstChild").get("data"):null},getNumber:function(e){if(!e)return!1;e=e.one(this.SELECTORS.NUMBER).get("text").replace(this.CONSTANTS.QUESTION,"");return!("number"!=typeof(e=parseInt(e,10))||!isFinite(e))&&e},setNumber:function(e,t){e.one(this.SELECTORS.NUMBER).setHTML(''+this.CONSTANTS.QUESTION+" "+t)},getSlots:function(){return u.all(this.SELECTORS.PAGECONTENT+" "+this.SELECTORS.SECTIONUL+" "+this.SELECTORS.SLOT)},getNumberedSlots:function(){var e=this.SELECTORS.PAGECONTENT+" "+this.SELECTORS.SECTIONUL;return e+=" "+this.SELECTORS.SLOT+":not("+this.SELECTORS.QUESTIONTYPEDESCRIPTION+")",u.all(e)},getPrevious:function(e){return e.previous(this.SELECTORS.SLOT)},getPreviousNumbered:function(e){var t,i,n=e.previous(this.SELECTORS.SLOT+":not("+this.SELECTORS.QUESTIONTYPEDESCRIPTION+")");if(n)return n;for(t=e.ancestor("li.section").previous("li.section");t;){if(0<(i=t.all(this.SELECTORS.SLOT+":not("+this.SELECTORS.QUESTIONTYPEDESCRIPTION+")")).size())return i.item(i.size()-1);t=t.previous("li.section")}return!1},reorderSlots:function(){this.getSlots().each(function(e){var t,i;u.Moodle.mod_quiz.util.page.getPageFromSlot(e)||(t=e.next(u.Moodle.mod_quiz.util.page.SELECTORS.PAGE),e.swap(t)),t=this.getPreviousNumbered(e),i=0,e.hasClass(this.CSS.QUESTIONTYPEDESCRIPTION)||(t&&(i=this.getNumber(t)),this.setNumber(e,i+1))},this)},updateOneSlotSections:function(){u.all(".mod-quiz-edit-content ul.slots li.section").each(function(e){1' + this.CONSTANTS.QUESTION + '
                ' + number); }, /** * Returns a list of all slot elements on the page. * * @method getSlots * @return {node[]} An array containing slot nodes. */ getSlots: function() { return Y.all(this.SELECTORS.PAGECONTENT + ' ' + this.SELECTORS.SECTIONUL + ' ' + this.SELECTORS.SLOT); }, /** * Returns a list of all slot elements on the page that have numbers. Excudes description questions. * * @method getSlots * @return {node[]} An array containing slot nodes. */ getNumberedSlots: function() { var selector = this.SELECTORS.PAGECONTENT + ' ' + this.SELECTORS.SECTIONUL; selector += ' ' + this.SELECTORS.SLOT + ':not(' + this.SELECTORS.QUESTIONTYPEDESCRIPTION + ')'; return Y.all(selector); }, /** * Returns the previous slot to the given slot. * * @method getPrevious * @param slot Slot node * @return {node|false} The previous slot node or false. */ getPrevious: function(slot) { return slot.previous(this.SELECTORS.SLOT); }, /** * Returns the previous numbered slot to the given slot. * * Ignores slots containing description question types. * * @method getPrevious * @param slot Slot node * @return {node|false} The previous slot node or false. */ getPreviousNumbered: function(slot) { var previous = slot.previous(this.SELECTORS.SLOT + ':not(' + this.SELECTORS.QUESTIONTYPEDESCRIPTION + ')'); if (previous) { return previous; } var section = slot.ancestor('li.section').previous('li.section'); while (section) { var questions = section.all(this.SELECTORS.SLOT + ':not(' + this.SELECTORS.QUESTIONTYPEDESCRIPTION + ')'); if (questions.size() > 0) { return questions.item(questions.size() - 1); } section = section.previous('li.section'); } return false; }, /** * Reset the order of the numbers given to each slot. * * @method reorderSlots * @return void */ reorderSlots: function() { // Get list of slot nodes. var slots = this.getSlots(); // Loop through slots incrementing the number each time. slots.each(function(slot) { if (!Y.Moodle.mod_quiz.util.page.getPageFromSlot(slot)) { // Move the next page to the front. var nextpage = slot.next(Y.Moodle.mod_quiz.util.page.SELECTORS.PAGE); slot.swap(nextpage); } var previousSlot = this.getPreviousNumbered(slot), previousslotnumber = 0; if (slot.hasClass(this.CSS.QUESTIONTYPEDESCRIPTION)) { return; } if (previousSlot) { previousslotnumber = this.getNumber(previousSlot); } // Set slot number. this.setNumber(slot, previousslotnumber + 1); }, this); }, /** * Add class only-has-one-slot to those sections that need it. * * @method updateOneSlotSections * @return void */ updateOneSlotSections: function() { Y.all('.mod-quiz-edit-content ul.slots li.section').each(function(section) { if (section.all(this.SELECTORS.SLOT).size() > 1) { section.removeClass('only-has-one-slot'); } else { section.addClass('only-has-one-slot'); } }, this); }, /** * Remove a slot and related elements from the list of slots. * * @method remove * @param slot Slot node * @return void */ remove: function(slot) { var page = Y.Moodle.mod_quiz.util.page.getPageFromSlot(slot); slot.remove(); // Is the page empty. if (!Y.Moodle.mod_quiz.util.page.isEmpty(page)) { return; } // If so remove it. Including add menu and page break. Y.Moodle.mod_quiz.util.page.remove(page); }, /** * Returns a list of all page break elements on the page. * * @method getPageBreaks * @return {node[]} An array containing page break nodes. */ getPageBreaks: function() { var selector = this.SELECTORS.PAGECONTENT + ' ' + this.SELECTORS.SECTIONUL; selector += ' ' + this.SELECTORS.SLOT + this.SELECTORS.PAGEBREAK; return Y.all(selector); }, /** * Retrieve the page break element item from the given slot. * * @method getPageBreak * @param slot Slot node * @return {Node|null} The Page Break Node. */ getPageBreak: function(slot) { return Y.one(slot).one(this.SELECTORS.PAGEBREAK); }, /** * Add a page break and related elements to the list of slots. * * @method addPageBreak * @param beforenode Int | Node | HTMLElement | String to add * @return pagebreak PageBreak node */ addPageBreak: function(slot) { var nodetext = M.mod_quiz.resource_toolbox.get('config').addpageiconhtml; nodetext = nodetext.replace('%%SLOT%%', this.getNumber(slot)); var pagebreak = Y.Node.create(nodetext); slot.one('div').insert(pagebreak, 'after'); return pagebreak; }, /** * Remove a pagebreak from the given slot. * * @method removePageBreak * @param slot Slot node * @return boolean */ removePageBreak: function(slot) { var pagebreak = this.getPageBreak(slot); if (!pagebreak) { return false; } pagebreak.remove(); return true; }, /** * Reorder each pagebreak by iterating through each related slot. * * @method reorderPageBreaks * @return void */ reorderPageBreaks: function() { // Get list of slot nodes. var slots = this.getSlots(); var slotnumber = 0; // Loop through slots incrementing the number each time. slots.each(function(slot, key) { slotnumber++; var pagebreak = this.getPageBreak(slot); var nextitem = slot.next('li.activity'); if (!nextitem) { // Last slot in a section. Should not have an icon. return; } // No pagebreak and not last slot. Add one. if (!pagebreak) { pagebreak = this.addPageBreak(slot); } // Remove last page break if there is one. if (pagebreak && key === slots.size() - 1) { this.removePageBreak(slot); } // Get page break anchor element. var pagebreaklink = pagebreak.get('childNodes').item(0); // Get the correct title. var action = ''; var iconname = ''; if (Y.Moodle.mod_quiz.util.page.isPage(nextitem)) { action = 'removepagebreak'; iconname = 'e/remove_page_break'; } else { action = 'addpagebreak'; iconname = 'e/insert_page_break'; } // Update the link and image titles pagebreaklink.set('title', M.util.get_string(action, 'quiz')); pagebreaklink.setData('action', action); // Update the image title. var icon = pagebreaklink.one(this.SELECTORS.ICON); icon.set('title', M.util.get_string(action, 'quiz')); icon.set('alt', M.util.get_string(action, 'quiz')); // Update the image src. icon.set('src', M.util.image_url(iconname)); // Get anchor url parameters as an associative array. var params = Y.QueryString.parse(pagebreaklink.get('href')); // Update slot number. params.slot = slotnumber; // Create the new url. var newurl = ''; for (var index in params) { if (newurl.length) { newurl += "&"; } newurl += index + "=" + params[index]; } // Update the anchor. pagebreaklink.set('href', newurl); }, this); }, /** * Update the dependency icons. * * @method updateAllDependencyIcons * @return void */ updateAllDependencyIcons: function() { // Get list of slot nodes. var slots = this.getSlots(), slotnumber = 0, previousslot = null; // Loop through slots incrementing the number each time. slots.each(function(slot) { slotnumber++; if (slotnumber == 1 || previousslot.getData('canfinish') === '0') { slot.one(this.SELECTORS.DEPENDENCY_WRAPPER).addClass(this.CSS.CANNOT_DEPEND); } else { slot.one(this.SELECTORS.DEPENDENCY_WRAPPER).removeClass(this.CSS.CANNOT_DEPEND); } this.updateDependencyIcon(slot, null); previousslot = slot; }, this); }, /** * Update the slot icon to indicate the new requiresprevious state. * * @method slot Slot node * @method requiresprevious Whether this node now requires the previous one. * @return void */ updateDependencyIcon: function(slot, requiresprevious) { var link = slot.one(this.SELECTORS.DEPENDENCY_LINK); var icon = slot.one(this.SELECTORS.DEPENDENCY_ICON); var previousSlot = this.getPrevious(slot); var a = {thisq: this.getNumber(slot)}; if (previousSlot) { a.previousq = this.getNumber(previousSlot); } if (requiresprevious === null) { requiresprevious = link.getData('action') === 'removedependency'; } if (requiresprevious) { link.set('title', M.util.get_string('questiondependencyremove', 'quiz', a)); link.setData('action', 'removedependency'); window.require(['core/templates'], function(Templates) { Templates.renderPix('t/locked', 'core', M.util.get_string('questiondependsonprevious', 'quiz')).then( function(html) { icon.replace(html); } ); }); } else { link.set('title', M.util.get_string('questiondependencyadd', 'quiz', a)); link.setData('action', 'adddependency'); window.require(['core/templates'], function(Templates) { Templates.renderPix('t/unlocked', 'core', M.util.get_string('questiondependencyfree', 'quiz')).then( function(html) { icon.replace(html); } ); }); } } }; }, '@VERSION@', {"requires": ["node", "moodle-mod_quiz-util-base"]}); PK9B\Yi@99@yui/build/moodle-mod_quiz-util-slot/moodle-mod_quiz-util-slot.jsnu[YUI.add('moodle-mod_quiz-util-slot', function (Y, NAME) { /** * A collection of utility classes for use with slots. * * @module moodle-mod_quiz-util * @submodule moodle-mod_quiz-util-slot */ Y.namespace('Moodle.mod_quiz.util.slot'); /** * A collection of utility classes for use with slots. * * @class Moodle.mod_quiz.util.slot * @static */ Y.Moodle.mod_quiz.util.slot = { CSS: { SLOT: 'slot', QUESTIONTYPEDESCRIPTION: 'qtype_description', CANNOT_DEPEND: 'question_dependency_cannot_depend' }, CONSTANTS: { SLOTIDPREFIX: 'slot-', QUESTION: M.util.get_string('question', 'moodle') }, SELECTORS: { SLOT: 'li.slot', INSTANCENAME: '.instancename', NUMBER: 'span.slotnumber', PAGECONTENT: 'div#page-content', PAGEBREAK: 'span.page_split_join_wrapper', ICON: '.icon', QUESTIONTYPEDESCRIPTION: '.qtype_description', SECTIONUL: 'ul.section', DEPENDENCY_WRAPPER: '.question_dependency_wrapper', DEPENDENCY_LINK: '.question_dependency_wrapper .cm-edit-action', DEPENDENCY_ICON: '.question_dependency_wrapper .icon' }, /** * Retrieve the slot item from one of it's child Nodes. * * @method getSlotFromComponent * @param slotcomponent {Node} The component Node. * @return {Node|null} The Slot Node. */ getSlotFromComponent: function(slotcomponent) { return Y.one(slotcomponent).ancestor(this.SELECTORS.SLOT, true); }, /** * Determines the slot ID for the provided slot. * * @method getId * @param slot {Node} The slot to find an ID for. * @return {Number|false} The ID of the slot in question or false if no ID was found. */ getId: function(slot) { // We perform a simple substitution operation to get the ID. var id = slot.get('id').replace( this.CONSTANTS.SLOTIDPREFIX, ''); // Attempt to validate the ID. id = parseInt(id, 10); if (typeof id === 'number' && isFinite(id)) { return id; } return false; }, /** * Determines the slot name for the provided slot. * * @method getName * @param slot {Node} The slot to find a name for. * @return {string|false} The name of the slot in question or false if no ID was found. */ getName: function(slot) { var instance = slot.one(this.SELECTORS.INSTANCENAME); if (instance) { return instance.get('firstChild').get('data'); } return null; }, /** * Determines the slot number for the provided slot. * * @method getNumber * @param slot {Node} The slot to find the number for. * @return {Number|false} The number of the slot in question or false if no number was found. */ getNumber: function(slot) { if (!slot) { return false; } // We perform a simple substitution operation to get the number. var number = slot.one(this.SELECTORS.NUMBER).get('text').replace( this.CONSTANTS.QUESTION, ''); // Attempt to validate the ID. number = parseInt(number, 10); if (typeof number === 'number' && isFinite(number)) { return number; } return false; }, /** * Updates the slot number for the provided slot. * * @method setNumber * @param slot {Node} The slot to update the number for. * @return void */ setNumber: function(slot, number) { var numbernode = slot.one(this.SELECTORS.NUMBER); numbernode.setHTML('' + this.CONSTANTS.QUESTION + ' ' + number); }, /** * Returns a list of all slot elements on the page. * * @method getSlots * @return {node[]} An array containing slot nodes. */ getSlots: function() { return Y.all(this.SELECTORS.PAGECONTENT + ' ' + this.SELECTORS.SECTIONUL + ' ' + this.SELECTORS.SLOT); }, /** * Returns a list of all slot elements on the page that have numbers. Excudes description questions. * * @method getSlots * @return {node[]} An array containing slot nodes. */ getNumberedSlots: function() { var selector = this.SELECTORS.PAGECONTENT + ' ' + this.SELECTORS.SECTIONUL; selector += ' ' + this.SELECTORS.SLOT + ':not(' + this.SELECTORS.QUESTIONTYPEDESCRIPTION + ')'; return Y.all(selector); }, /** * Returns the previous slot to the given slot. * * @method getPrevious * @param slot Slot node * @return {node|false} The previous slot node or false. */ getPrevious: function(slot) { return slot.previous(this.SELECTORS.SLOT); }, /** * Returns the previous numbered slot to the given slot. * * Ignores slots containing description question types. * * @method getPrevious * @param slot Slot node * @return {node|false} The previous slot node or false. */ getPreviousNumbered: function(slot) { var previous = slot.previous(this.SELECTORS.SLOT + ':not(' + this.SELECTORS.QUESTIONTYPEDESCRIPTION + ')'); if (previous) { return previous; } var section = slot.ancestor('li.section').previous('li.section'); while (section) { var questions = section.all(this.SELECTORS.SLOT + ':not(' + this.SELECTORS.QUESTIONTYPEDESCRIPTION + ')'); if (questions.size() > 0) { return questions.item(questions.size() - 1); } section = section.previous('li.section'); } return false; }, /** * Reset the order of the numbers given to each slot. * * @method reorderSlots * @return void */ reorderSlots: function() { // Get list of slot nodes. var slots = this.getSlots(); // Loop through slots incrementing the number each time. slots.each(function(slot) { if (!Y.Moodle.mod_quiz.util.page.getPageFromSlot(slot)) { // Move the next page to the front. var nextpage = slot.next(Y.Moodle.mod_quiz.util.page.SELECTORS.PAGE); slot.swap(nextpage); } var previousSlot = this.getPreviousNumbered(slot), previousslotnumber = 0; if (slot.hasClass(this.CSS.QUESTIONTYPEDESCRIPTION)) { return; } if (previousSlot) { previousslotnumber = this.getNumber(previousSlot); } // Set slot number. this.setNumber(slot, previousslotnumber + 1); }, this); }, /** * Add class only-has-one-slot to those sections that need it. * * @method updateOneSlotSections * @return void */ updateOneSlotSections: function() { Y.all('.mod-quiz-edit-content ul.slots li.section').each(function(section) { if (section.all(this.SELECTORS.SLOT).size() > 1) { section.removeClass('only-has-one-slot'); } else { section.addClass('only-has-one-slot'); } }, this); }, /** * Remove a slot and related elements from the list of slots. * * @method remove * @param slot Slot node * @return void */ remove: function(slot) { var page = Y.Moodle.mod_quiz.util.page.getPageFromSlot(slot); slot.remove(); // Is the page empty. if (!Y.Moodle.mod_quiz.util.page.isEmpty(page)) { return; } // If so remove it. Including add menu and page break. Y.Moodle.mod_quiz.util.page.remove(page); }, /** * Returns a list of all page break elements on the page. * * @method getPageBreaks * @return {node[]} An array containing page break nodes. */ getPageBreaks: function() { var selector = this.SELECTORS.PAGECONTENT + ' ' + this.SELECTORS.SECTIONUL; selector += ' ' + this.SELECTORS.SLOT + this.SELECTORS.PAGEBREAK; return Y.all(selector); }, /** * Retrieve the page break element item from the given slot. * * @method getPageBreak * @param slot Slot node * @return {Node|null} The Page Break Node. */ getPageBreak: function(slot) { return Y.one(slot).one(this.SELECTORS.PAGEBREAK); }, /** * Add a page break and related elements to the list of slots. * * @method addPageBreak * @param beforenode Int | Node | HTMLElement | String to add * @return pagebreak PageBreak node */ addPageBreak: function(slot) { var nodetext = M.mod_quiz.resource_toolbox.get('config').addpageiconhtml; nodetext = nodetext.replace('%%SLOT%%', this.getNumber(slot)); var pagebreak = Y.Node.create(nodetext); slot.one('div').insert(pagebreak, 'after'); return pagebreak; }, /** * Remove a pagebreak from the given slot. * * @method removePageBreak * @param slot Slot node * @return boolean */ removePageBreak: function(slot) { var pagebreak = this.getPageBreak(slot); if (!pagebreak) { return false; } pagebreak.remove(); return true; }, /** * Reorder each pagebreak by iterating through each related slot. * * @method reorderPageBreaks * @return void */ reorderPageBreaks: function() { // Get list of slot nodes. var slots = this.getSlots(); var slotnumber = 0; // Loop through slots incrementing the number each time. slots.each(function(slot, key) { slotnumber++; var pagebreak = this.getPageBreak(slot); var nextitem = slot.next('li.activity'); if (!nextitem) { // Last slot in a section. Should not have an icon. return; } // No pagebreak and not last slot. Add one. if (!pagebreak) { pagebreak = this.addPageBreak(slot); } // Remove last page break if there is one. if (pagebreak && key === slots.size() - 1) { this.removePageBreak(slot); } // Get page break anchor element. var pagebreaklink = pagebreak.get('childNodes').item(0); // Get the correct title. var action = ''; var iconname = ''; if (Y.Moodle.mod_quiz.util.page.isPage(nextitem)) { action = 'removepagebreak'; iconname = 'e/remove_page_break'; } else { action = 'addpagebreak'; iconname = 'e/insert_page_break'; } // Update the link and image titles pagebreaklink.set('title', M.util.get_string(action, 'quiz')); pagebreaklink.setData('action', action); // Update the image title. var icon = pagebreaklink.one(this.SELECTORS.ICON); icon.set('title', M.util.get_string(action, 'quiz')); icon.set('alt', M.util.get_string(action, 'quiz')); // Update the image src. icon.set('src', M.util.image_url(iconname)); // Get anchor url parameters as an associative array. var params = Y.QueryString.parse(pagebreaklink.get('href')); // Update slot number. params.slot = slotnumber; // Create the new url. var newurl = ''; for (var index in params) { if (newurl.length) { newurl += "&"; } newurl += index + "=" + params[index]; } // Update the anchor. pagebreaklink.set('href', newurl); }, this); }, /** * Update the dependency icons. * * @method updateAllDependencyIcons * @return void */ updateAllDependencyIcons: function() { // Get list of slot nodes. var slots = this.getSlots(), slotnumber = 0, previousslot = null; // Loop through slots incrementing the number each time. slots.each(function(slot) { slotnumber++; if (slotnumber == 1 || previousslot.getData('canfinish') === '0') { slot.one(this.SELECTORS.DEPENDENCY_WRAPPER).addClass(this.CSS.CANNOT_DEPEND); } else { slot.one(this.SELECTORS.DEPENDENCY_WRAPPER).removeClass(this.CSS.CANNOT_DEPEND); } this.updateDependencyIcon(slot, null); previousslot = slot; }, this); }, /** * Update the slot icon to indicate the new requiresprevious state. * * @method slot Slot node * @method requiresprevious Whether this node now requires the previous one. * @return void */ updateDependencyIcon: function(slot, requiresprevious) { var link = slot.one(this.SELECTORS.DEPENDENCY_LINK); var icon = slot.one(this.SELECTORS.DEPENDENCY_ICON); var previousSlot = this.getPrevious(slot); var a = {thisq: this.getNumber(slot)}; if (previousSlot) { a.previousq = this.getNumber(previousSlot); } if (requiresprevious === null) { requiresprevious = link.getData('action') === 'removedependency'; } if (requiresprevious) { link.set('title', M.util.get_string('questiondependencyremove', 'quiz', a)); link.setData('action', 'removedependency'); window.require(['core/templates'], function(Templates) { Templates.renderPix('t/locked', 'core', M.util.get_string('questiondependsonprevious', 'quiz')).then( function(html) { icon.replace(html); } ); }); } else { link.set('title', M.util.get_string('questiondependencyadd', 'quiz', a)); link.setData('action', 'adddependency'); window.require(['core/templates'], function(Templates) { Templates.renderPix('t/unlocked', 'core', M.util.get_string('questiondependencyfree', 'quiz')).then( function(html) { icon.replace(html); } ); }); } } }; }, '@VERSION@', {"requires": ["node", "moodle-mod_quiz-util-base"]}); PK9B\>>Byui/build/moodle-mod_quiz-autosave/moodle-mod_quiz-autosave-min.jsnu[YUI.add("moodle-mod_quiz-autosave",function(n,t){M.mod_quiz=M.mod_quiz||{},M.mod_quiz.autosave={TINYMCE_DETECTION_DELAY:500,TINYMCE_DETECTION_REPEATS:20,WATCH_HIDDEN_DELAY:1e3,FAILURES_BEFORE_NOTIFY:1,FIRST_SUCCESSFUL_SAVE:-1,SELECTORS:{QUIZ_FORM:"#responseform",VALUE_CHANGE_ELEMENTS:'input, textarea, [contenteditable="true"]',CHANGE_ELEMENTS:"input, select",HIDDEN_INPUTS:"input[type=hidden]",CONNECTION_ERROR:"#connection-error",CONNECTION_OK:"#connection-ok"},AUTOSAVE_HANDLER:M.cfg.wwwroot+"/mod/quiz/autosave.ajax.php",delay:12e4,form:null,dirty:!1,delay_timer:null,save_transaction:null,savefailures:0,editor_change_handler:null,hidden_field_values:{},init:function(t){this.form=n.one(this.SELECTORS.QUIZ_FORM),this.form&&(this.delay=1e3*t,this.form.delegate("valuechange",this.value_changed,this.SELECTORS.VALUE_CHANGE_ELEMENTS,this),this.form.delegate("change",this.value_changed,this.SELECTORS.CHANGE_ELEMENTS,this),this.form.on("submit",this.stop_autosaving,this),require(["core_form/events"],function(t){window.addEventListener(t.eventTypes.uploadChanged,this.value_changed.bind(this))}.bind(this)),this.init_tinymce(this.TINYMCE_DETECTION_REPEATS),this.save_hidden_field_values(),this.watch_hidden_fields())},save_hidden_field_values:function(){this.form.all(this.SELECTORS.HIDDEN_INPUTS).each(function(t){var e=t.get("name");e&&(this.hidden_field_values[e]=t.get("value"))},this)},watch_hidden_fields:function(){this.detect_hidden_field_changes(),n.later(this.WATCH_HIDDEN_DELAY,this,this.watch_hidden_fields)},detect_hidden_field_changes:function(){this.form.all(this.SELECTORS.HIDDEN_INPUTS).each(function(t){var e=t.get("name"),i=t.get("value");e&&(e in this.hidden_field_values&&i===this.hidden_field_values[e]||(this.hidden_field_values[e]=i,this.value_changed({target:t})))},this)},init_tinymce:function(t){var e;"undefined"!=typeof window.tinyMCE?(this.editor_change_handler=n.bind(this.editor_changed,this),window.tinyMCE.onAddEditor?window.tinyMCE.onAddEditor.add(n.bind(this.init_tinymce_editor,this)):window.tinyMCE.on&&(e=this.start_save_timer_if_necessary.bind(this),window.tinyMCE.on("AddEditor",function(t){t.editor.on("Change Undo Redo keydown",e)}),window.tinyMCE.get().forEach(function(t){t.on("Change Undo Redo keydown",e)}))):0M.mod_quiz.timer.endtime},stop_autosaving:function(){this.cancel_delay(),this.delay_timer=!0,this.save_transaction&&this.save_transaction.abort()}}},"@VERSION@",{requires:["base","node","event","event-valuechange","node-event-delegate","io-form","datatype-date-format"]});PK9B\acpW44>yui/build/moodle-mod_quiz-autosave/moodle-mod_quiz-autosave.jsnu[YUI.add('moodle-mod_quiz-autosave', 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 . /** * Auto-save functionality for during quiz attempts. * * @module moodle-mod_quiz-autosave */ /** * Auto-save functionality for during quiz attempts. * * @class M.mod_quiz.autosave */ M.mod_quiz = M.mod_quiz || {}; M.mod_quiz.autosave = { /** * The amount of time (in milliseconds) to wait between TinyMCE detections. * * @property TINYMCE_DETECTION_DELAY * @type Number * @default 500 * @private */ TINYMCE_DETECTION_DELAY: 500, /** * The number of times to try redetecting TinyMCE. * * @property TINYMCE_DETECTION_REPEATS * @type Number * @default 20 * @private */ TINYMCE_DETECTION_REPEATS: 20, /** * The delay (in milliseconds) between checking hidden input fields. * * @property WATCH_HIDDEN_DELAY * @type Number * @default 1000 * @private */ WATCH_HIDDEN_DELAY: 1000, /** * The number of failures to ignore before notifying the user. * * @property FAILURES_BEFORE_NOTIFY * @type Number * @default 1 * @private */ FAILURES_BEFORE_NOTIFY: 1, /** * The value to use when resetting the successful save counter. * * @property FIRST_SUCCESSFUL_SAVE * @static * @type Number * @default -1 * @private */ FIRST_SUCCESSFUL_SAVE: -1, /** * The selectors used throughout this class. * * @property SELECTORS * @private * @type Object * @static */ SELECTORS: { QUIZ_FORM: '#responseform', VALUE_CHANGE_ELEMENTS: 'input, textarea, [contenteditable="true"]', CHANGE_ELEMENTS: 'input, select', HIDDEN_INPUTS: 'input[type=hidden]', CONNECTION_ERROR: '#connection-error', CONNECTION_OK: '#connection-ok' }, /** * The script which handles the autosaves. * * @property AUTOSAVE_HANDLER * @type String * @default M.cfg.wwwroot + '/mod/quiz/autosave.ajax.php' * @private */ AUTOSAVE_HANDLER: M.cfg.wwwroot + '/mod/quiz/autosave.ajax.php', /** * The delay (in milliseconds) between a change being made, and it being auto-saved. * * @property delay * @type Number * @default 120000 * @private */ delay: 120000, /** * A Node reference to the form we are monitoring. * * @property form * @type Node * @default null */ form: null, /** * Whether the form has been modified since the last save started. * * @property dirty * @type boolean * @default false */ dirty: false, /** * Timer object for the delay between form modifaction and the save starting. * * @property delay_timer * @type Object * @default null * @private */ delay_timer: null, /** * Y.io transaction for the save ajax request. * * @property save_transaction * @type object * @default null */ save_transaction: null, /** * Failed saves count. * * @property savefailures * @type Number * @default 0 * @private */ savefailures: 0, /** * Properly bound key change handler. * * @property editor_change_handler * @type EventHandle * @default null * @private */ editor_change_handler: null, /** * Record of the value of all the hidden fields, last time they were checked. * * @property hidden_field_values * @type Object * @default {} */ hidden_field_values: {}, /** * Initialise the autosave code. * * @method init * @param {Number} delay the delay, in seconds, between a change being detected, and * a save happening. */ init: function(delay) { this.form = Y.one(this.SELECTORS.QUIZ_FORM); if (!this.form) { return; } this.delay = delay * 1000; this.form.delegate('valuechange', this.value_changed, this.SELECTORS.VALUE_CHANGE_ELEMENTS, this); this.form.delegate('change', this.value_changed, this.SELECTORS.CHANGE_ELEMENTS, this); this.form.on('submit', this.stop_autosaving, this); require(['core_form/events'], function(FormEvent) { window.addEventListener(FormEvent.eventTypes.uploadChanged, this.value_changed.bind(this)); }.bind(this)); this.init_tinymce(this.TINYMCE_DETECTION_REPEATS); this.save_hidden_field_values(); this.watch_hidden_fields(); }, save_hidden_field_values: function() { this.form.all(this.SELECTORS.HIDDEN_INPUTS).each(function(hidden) { var name = hidden.get('name'); if (!name) { return; } this.hidden_field_values[name] = hidden.get('value'); }, this); }, watch_hidden_fields: function() { this.detect_hidden_field_changes(); Y.later(this.WATCH_HIDDEN_DELAY, this, this.watch_hidden_fields); }, detect_hidden_field_changes: function() { this.form.all(this.SELECTORS.HIDDEN_INPUTS).each(function(hidden) { var name = hidden.get('name'), value = hidden.get('value'); if (!name) { return; } if (!(name in this.hidden_field_values) || value !== this.hidden_field_values[name]) { this.hidden_field_values[name] = value; this.value_changed({target: hidden}); } }, this); }, /** * Initialise watching of TinyMCE specifically. * * Because TinyMCE might load slowly, and after us, we need to keep * trying, until we detect TinyMCE is there, or enough time has passed. * This is based on the TINYMCE_DETECTION_DELAY and * TINYMCE_DETECTION_REPEATS properties. * * * @method init_tinymce * @param {Number} repeatcount The number of attempts made so far. */ init_tinymce: function(repeatcount) { if (typeof window.tinyMCE === 'undefined') { if (repeatcount > 0) { Y.later(this.TINYMCE_DETECTION_DELAY, this, this.init_tinymce, [repeatcount - 1]); } else { } return; } this.editor_change_handler = Y.bind(this.editor_changed, this); if (window.tinyMCE.onAddEditor) { window.tinyMCE.onAddEditor.add(Y.bind(this.init_tinymce_editor, this)); } else if (window.tinyMCE.on) { var startSaveTimer = this.start_save_timer_if_necessary.bind(this); window.tinyMCE.on('AddEditor', function(event) { event.editor.on('Change Undo Redo keydown', startSaveTimer); }); // One or more editors might already have been added, so we have to attach // the event handlers to these as well. window.tinyMCE.get().forEach(function(editor) { editor.on('Change Undo Redo keydown', startSaveTimer); }); } }, /** * Initialise watching of a specific TinyMCE editor. * * @method init_tinymce_editor * @param {EventFacade} e * @param {Object} editor The TinyMCE editor object */ init_tinymce_editor: function(e, editor) { editor.onChange.add(this.editor_change_handler); editor.onRedo.add(this.editor_change_handler); editor.onUndo.add(this.editor_change_handler); editor.onKeyDown.add(this.editor_change_handler); }, value_changed: function(e) { var name = e.target.getAttribute('name'); if (name === 'thispage' || name === 'scrollpos' || (name && name.match(/_:flagged$/))) { return; // Not interesting. } // Fallback to the ID when the name is not present (in the case of content editable). name = name || '#' + e.target.getAttribute('id'); this.start_save_timer_if_necessary(); }, editor_changed: function(editor) { this.start_save_timer_if_necessary(); }, start_save_timer_if_necessary: function() { this.dirty = true; if (this.delay_timer || this.save_transaction) { // Already counting down or daving. return; } this.start_save_timer(); }, start_save_timer: function() { this.cancel_delay(); this.delay_timer = Y.later(this.delay, this, this.save_changes); }, cancel_delay: function() { if (this.delay_timer && this.delay_timer !== true) { this.delay_timer.cancel(); } this.delay_timer = null; }, save_changes: function() { this.cancel_delay(); this.dirty = false; if (this.is_time_nearly_over()) { this.stop_autosaving(); return; } if (typeof window.tinyMCE !== 'undefined') { window.tinyMCE.triggerSave(); } // YUI io.form incorrectly (in my opinion) sends the value of all submit // buttons in the ajax request. We don't want any submit buttons. // Therefore, temporarily change the type. // (Yes, this is a nasty hack. One day this will be re-written as AMD, hopefully). var allsubmitbuttons = this.form.all('input[type=submit], button[type=submit]'); allsubmitbuttons.setAttribute('type', 'button'); this.save_transaction = Y.io(this.AUTOSAVE_HANDLER, { method: 'POST', form: {id: this.form}, on: { success: this.save_done, failure: this.save_failed }, context: this }); // Change the button types back. allsubmitbuttons.setAttribute('type', 'submit'); }, save_done: function(transactionid, response) { var autosavedata = JSON.parse(response.responseText); if (autosavedata.status !== 'OK') { // Because IIS is useless, Moodle can't send proper HTTP response // codes, so we have to detect failures manually. this.save_failed(transactionid, response); return; } if (typeof autosavedata.timeleft !== 'undefined') { M.mod_quiz.timer.updateEndTime(autosavedata.timeleft); } this.update_saved_time_display(); this.save_transaction = null; if (this.dirty) { this.start_save_timer(); } if (this.savefailures > 0) { Y.one(this.SELECTORS.CONNECTION_ERROR).hide(); Y.one(this.SELECTORS.CONNECTION_OK).show(); this.savefailures = this.FIRST_SUCCESSFUL_SAVE; } else if (this.savefailures === this.FIRST_SUCCESSFUL_SAVE) { Y.one(this.SELECTORS.CONNECTION_OK).hide(); this.savefailures = 0; } }, save_failed: function() { this.save_transaction = null; // We want to retry soon. this.start_save_timer(); this.savefailures = Math.max(1, this.savefailures + 1); if (this.savefailures === this.FAILURES_BEFORE_NOTIFY) { Y.one(this.SELECTORS.CONNECTION_ERROR).show(); Y.one(this.SELECTORS.CONNECTION_OK).hide(); } }, /** * Inform the user that their answers have been saved. * * @method update_saved_time_display */ update_saved_time_display: function() { // We fetch the current language's preferred time format from the language pack. require(['core/user_date', 'core/notification'], function(UserDate, Notification) { UserDate.get([{ timestamp: Math.floor(Date.now() / 1000), format: M.util.get_string('strftimedatetimeshortaccurate', 'langconfig'), }]).then(function(dateStrs) { var infoDiv = Y.one('#mod_quiz_navblock .othernav .autosave_info'); infoDiv.set('text', M.util.get_string('lastautosave', 'quiz', dateStrs[0])); infoDiv.show(); return; }).catch(Notification.exception); }); }, is_time_nearly_over: function() { return M.mod_quiz.timer && M.mod_quiz.timer.endtime && (new Date().getTime() + 2 * this.delay) > M.mod_quiz.timer.endtime; }, stop_autosaving: function() { this.cancel_delay(); this.delay_timer = true; if (this.save_transaction) { this.save_transaction.abort(); } } }; }, '@VERSION@', { "requires": [ "base", "node", "event", "event-valuechange", "node-event-delegate", "io-form", "datatype-date-format" ] }); PK9B\3&9&9Dyui/build/moodle-mod_quiz-autosave/moodle-mod_quiz-autosave-debug.jsnu[YUI.add('moodle-mod_quiz-autosave', 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 . /** * Auto-save functionality for during quiz attempts. * * @module moodle-mod_quiz-autosave */ /** * Auto-save functionality for during quiz attempts. * * @class M.mod_quiz.autosave */ M.mod_quiz = M.mod_quiz || {}; M.mod_quiz.autosave = { /** * The amount of time (in milliseconds) to wait between TinyMCE detections. * * @property TINYMCE_DETECTION_DELAY * @type Number * @default 500 * @private */ TINYMCE_DETECTION_DELAY: 500, /** * The number of times to try redetecting TinyMCE. * * @property TINYMCE_DETECTION_REPEATS * @type Number * @default 20 * @private */ TINYMCE_DETECTION_REPEATS: 20, /** * The delay (in milliseconds) between checking hidden input fields. * * @property WATCH_HIDDEN_DELAY * @type Number * @default 1000 * @private */ WATCH_HIDDEN_DELAY: 1000, /** * The number of failures to ignore before notifying the user. * * @property FAILURES_BEFORE_NOTIFY * @type Number * @default 1 * @private */ FAILURES_BEFORE_NOTIFY: 1, /** * The value to use when resetting the successful save counter. * * @property FIRST_SUCCESSFUL_SAVE * @static * @type Number * @default -1 * @private */ FIRST_SUCCESSFUL_SAVE: -1, /** * The selectors used throughout this class. * * @property SELECTORS * @private * @type Object * @static */ SELECTORS: { QUIZ_FORM: '#responseform', VALUE_CHANGE_ELEMENTS: 'input, textarea, [contenteditable="true"]', CHANGE_ELEMENTS: 'input, select', HIDDEN_INPUTS: 'input[type=hidden]', CONNECTION_ERROR: '#connection-error', CONNECTION_OK: '#connection-ok' }, /** * The script which handles the autosaves. * * @property AUTOSAVE_HANDLER * @type String * @default M.cfg.wwwroot + '/mod/quiz/autosave.ajax.php' * @private */ AUTOSAVE_HANDLER: M.cfg.wwwroot + '/mod/quiz/autosave.ajax.php', /** * The delay (in milliseconds) between a change being made, and it being auto-saved. * * @property delay * @type Number * @default 120000 * @private */ delay: 120000, /** * A Node reference to the form we are monitoring. * * @property form * @type Node * @default null */ form: null, /** * Whether the form has been modified since the last save started. * * @property dirty * @type boolean * @default false */ dirty: false, /** * Timer object for the delay between form modifaction and the save starting. * * @property delay_timer * @type Object * @default null * @private */ delay_timer: null, /** * Y.io transaction for the save ajax request. * * @property save_transaction * @type object * @default null */ save_transaction: null, /** * Failed saves count. * * @property savefailures * @type Number * @default 0 * @private */ savefailures: 0, /** * Properly bound key change handler. * * @property editor_change_handler * @type EventHandle * @default null * @private */ editor_change_handler: null, /** * Record of the value of all the hidden fields, last time they were checked. * * @property hidden_field_values * @type Object * @default {} */ hidden_field_values: {}, /** * Initialise the autosave code. * * @method init * @param {Number} delay the delay, in seconds, between a change being detected, and * a save happening. */ init: function(delay) { this.form = Y.one(this.SELECTORS.QUIZ_FORM); if (!this.form) { Y.log('No response form found. Why did you try to set up autosave?', 'debug', 'moodle-mod_quiz-autosave'); return; } this.delay = delay * 1000; this.form.delegate('valuechange', this.value_changed, this.SELECTORS.VALUE_CHANGE_ELEMENTS, this); this.form.delegate('change', this.value_changed, this.SELECTORS.CHANGE_ELEMENTS, this); this.form.on('submit', this.stop_autosaving, this); require(['core_form/events'], function(FormEvent) { window.addEventListener(FormEvent.eventTypes.uploadChanged, this.value_changed.bind(this)); }.bind(this)); this.init_tinymce(this.TINYMCE_DETECTION_REPEATS); this.save_hidden_field_values(); this.watch_hidden_fields(); }, save_hidden_field_values: function() { this.form.all(this.SELECTORS.HIDDEN_INPUTS).each(function(hidden) { var name = hidden.get('name'); if (!name) { return; } this.hidden_field_values[name] = hidden.get('value'); }, this); }, watch_hidden_fields: function() { this.detect_hidden_field_changes(); Y.later(this.WATCH_HIDDEN_DELAY, this, this.watch_hidden_fields); }, detect_hidden_field_changes: function() { this.form.all(this.SELECTORS.HIDDEN_INPUTS).each(function(hidden) { var name = hidden.get('name'), value = hidden.get('value'); if (!name) { return; } if (!(name in this.hidden_field_values) || value !== this.hidden_field_values[name]) { this.hidden_field_values[name] = value; this.value_changed({target: hidden}); } }, this); }, /** * Initialise watching of TinyMCE specifically. * * Because TinyMCE might load slowly, and after us, we need to keep * trying, until we detect TinyMCE is there, or enough time has passed. * This is based on the TINYMCE_DETECTION_DELAY and * TINYMCE_DETECTION_REPEATS properties. * * * @method init_tinymce * @param {Number} repeatcount The number of attempts made so far. */ init_tinymce: function(repeatcount) { if (typeof window.tinyMCE === 'undefined') { if (repeatcount > 0) { Y.later(this.TINYMCE_DETECTION_DELAY, this, this.init_tinymce, [repeatcount - 1]); } else { Y.log('Gave up looking for TinyMCE.', 'debug', 'moodle-mod_quiz-autosave'); } return; } Y.log('Found TinyMCE.', 'debug', 'moodle-mod_quiz-autosave'); this.editor_change_handler = Y.bind(this.editor_changed, this); if (window.tinyMCE.onAddEditor) { window.tinyMCE.onAddEditor.add(Y.bind(this.init_tinymce_editor, this)); } else if (window.tinyMCE.on) { var startSaveTimer = this.start_save_timer_if_necessary.bind(this); window.tinyMCE.on('AddEditor', function(event) { event.editor.on('Change Undo Redo keydown', startSaveTimer); }); // One or more editors might already have been added, so we have to attach // the event handlers to these as well. window.tinyMCE.get().forEach(function(editor) { editor.on('Change Undo Redo keydown', startSaveTimer); }); } }, /** * Initialise watching of a specific TinyMCE editor. * * @method init_tinymce_editor * @param {EventFacade} e * @param {Object} editor The TinyMCE editor object */ init_tinymce_editor: function(e, editor) { Y.log('Found TinyMCE editor ' + editor.id + '.', 'debug', 'moodle-mod_quiz-autosave'); editor.onChange.add(this.editor_change_handler); editor.onRedo.add(this.editor_change_handler); editor.onUndo.add(this.editor_change_handler); editor.onKeyDown.add(this.editor_change_handler); }, value_changed: function(e) { var name = e.target.getAttribute('name'); if (name === 'thispage' || name === 'scrollpos' || (name && name.match(/_:flagged$/))) { return; // Not interesting. } // Fallback to the ID when the name is not present (in the case of content editable). name = name || '#' + e.target.getAttribute('id'); Y.log('Detected a value change in element ' + name + '.', 'debug', 'moodle-mod_quiz-autosave'); this.start_save_timer_if_necessary(); }, editor_changed: function(editor) { Y.log('Detected a value change in editor ' + editor.id + '.', 'debug', 'moodle-mod_quiz-autosave'); this.start_save_timer_if_necessary(); }, start_save_timer_if_necessary: function() { this.dirty = true; if (this.delay_timer || this.save_transaction) { // Already counting down or daving. return; } this.start_save_timer(); }, start_save_timer: function() { this.cancel_delay(); this.delay_timer = Y.later(this.delay, this, this.save_changes); }, cancel_delay: function() { if (this.delay_timer && this.delay_timer !== true) { this.delay_timer.cancel(); } this.delay_timer = null; }, save_changes: function() { this.cancel_delay(); this.dirty = false; if (this.is_time_nearly_over()) { Y.log('No more saving, time is nearly over.', 'debug', 'moodle-mod_quiz-autosave'); this.stop_autosaving(); return; } Y.log('Doing a save.', 'debug', 'moodle-mod_quiz-autosave'); if (typeof window.tinyMCE !== 'undefined') { window.tinyMCE.triggerSave(); } // YUI io.form incorrectly (in my opinion) sends the value of all submit // buttons in the ajax request. We don't want any submit buttons. // Therefore, temporarily change the type. // (Yes, this is a nasty hack. One day this will be re-written as AMD, hopefully). var allsubmitbuttons = this.form.all('input[type=submit], button[type=submit]'); allsubmitbuttons.setAttribute('type', 'button'); this.save_transaction = Y.io(this.AUTOSAVE_HANDLER, { method: 'POST', form: {id: this.form}, on: { success: this.save_done, failure: this.save_failed }, context: this }); // Change the button types back. allsubmitbuttons.setAttribute('type', 'submit'); }, save_done: function(transactionid, response) { var autosavedata = JSON.parse(response.responseText); if (autosavedata.status !== 'OK') { // Because IIS is useless, Moodle can't send proper HTTP response // codes, so we have to detect failures manually. this.save_failed(transactionid, response); return; } if (typeof autosavedata.timeleft !== 'undefined') { Y.log('Updating timer: ' + autosavedata.timeleft + ' seconds remain.', 'debug', 'moodle-mod_quiz-timer'); M.mod_quiz.timer.updateEndTime(autosavedata.timeleft); } this.update_saved_time_display(); Y.log('Save completed.', 'debug', 'moodle-mod_quiz-autosave'); this.save_transaction = null; if (this.dirty) { Y.log('Dirty after save.', 'debug', 'moodle-mod_quiz-autosave'); this.start_save_timer(); } if (this.savefailures > 0) { Y.one(this.SELECTORS.CONNECTION_ERROR).hide(); Y.one(this.SELECTORS.CONNECTION_OK).show(); this.savefailures = this.FIRST_SUCCESSFUL_SAVE; } else if (this.savefailures === this.FIRST_SUCCESSFUL_SAVE) { Y.one(this.SELECTORS.CONNECTION_OK).hide(); this.savefailures = 0; } }, save_failed: function() { Y.log('Save failed.', 'debug', 'moodle-mod_quiz-autosave'); this.save_transaction = null; // We want to retry soon. this.start_save_timer(); this.savefailures = Math.max(1, this.savefailures + 1); if (this.savefailures === this.FAILURES_BEFORE_NOTIFY) { Y.one(this.SELECTORS.CONNECTION_ERROR).show(); Y.one(this.SELECTORS.CONNECTION_OK).hide(); } }, /** * Inform the user that their answers have been saved. * * @method update_saved_time_display */ update_saved_time_display: function() { // We fetch the current language's preferred time format from the language pack. require(['core/user_date', 'core/notification'], function(UserDate, Notification) { UserDate.get([{ timestamp: Math.floor(Date.now() / 1000), format: M.util.get_string('strftimedatetimeshortaccurate', 'langconfig'), }]).then(function(dateStrs) { var infoDiv = Y.one('#mod_quiz_navblock .othernav .autosave_info'); infoDiv.set('text', M.util.get_string('lastautosave', 'quiz', dateStrs[0])); infoDiv.show(); return; }).catch(Notification.exception); }); }, is_time_nearly_over: function() { return M.mod_quiz.timer && M.mod_quiz.timer.endtime && (new Date().getTime() + 2 * this.delay) > M.mod_quiz.timer.endtime; }, stop_autosaving: function() { this.cancel_delay(); this.delay_timer = true; if (this.save_transaction) { this.save_transaction.abort(); } } }; }, '@VERSION@', { "requires": [ "base", "node", "event", "event-valuechange", "node-event-delegate", "io-form", "datatype-date-format" ] }); PK9B\= report.phpnu[. /** * This script controls the display of the quiz reports. * * @package mod_quiz * @copyright 1999 onwards Martin Dougiamas {@link http://moodle.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ use mod_quiz\quiz_settings; define('NO_OUTPUT_BUFFERING', true); require_once(__DIR__ . '/../../config.php'); require_once($CFG->dirroot . '/mod/quiz/locallib.php'); require_once($CFG->dirroot . '/mod/quiz/report/reportlib.php'); $id = optional_param('id', 0, PARAM_INT); $q = optional_param('q', 0, PARAM_INT); $mode = optional_param('mode', '', PARAM_ALPHA); if ($id) { $quizobj = quiz_settings::create_for_cmid($id); } else { $quizobj = quiz_settings::create($q); } $quiz = $quizobj->get_quiz(); $cm = $quizobj->get_cm(); $course = $quizobj->get_course(); $url = new moodle_url('/mod/quiz/report.php', ['id' => $cm->id]); if ($mode !== '') { $url->param('mode', $mode); } $PAGE->set_url($url); require_login($course, false, $cm); $PAGE->set_pagelayout('report'); $PAGE->activityheader->disable(); $reportlist = quiz_report_list($quizobj->get_context()); if (empty($reportlist)) { throw new \moodle_exception('erroraccessingreport', 'quiz'); } // Validate the requested report name. if ($mode == '') { // Default to first accessible report and redirect. $url->param('mode', reset($reportlist)); redirect($url); } else if (!in_array($mode, $reportlist)) { throw new \moodle_exception('erroraccessingreport', 'quiz'); } if (!is_readable("report/$mode/report.php")) { throw new \moodle_exception('reportnotfound', 'quiz', '', $mode); } // Open the selected quiz report and display it. $file = $CFG->dirroot . '/mod/quiz/report/' . $mode . '/report.php'; if (is_readable($file)) { include_once($file); } $reportclassname = 'quiz_' . $mode . '_report'; if (!class_exists($reportclassname)) { throw new \moodle_exception('preprocesserror', 'quiz'); } $report = new $reportclassname(); $report->display($quiz, $cm, $course); // Print footer. echo $OUTPUT->footer(); // Log that this report was viewed. $params = [ 'context' => $quizobj->get_context(), 'other' => [ 'quizid' => $quiz->id, 'reportname' => $mode ] ]; $event = \mod_quiz\event\report_viewed::create($params); $event->add_record_snapshot('course', $course); $event->add_record_snapshot('quiz', $quiz); $event->trigger(); PK9B\P*%55attemptlib.phpnu[. /** * File only retained to prevent fatal errors in code that tries to require/include this. * * @todo MDL-76612 delete this file as part of Moodle 4.6 development. * @deprecated This file is no longer required in Moodle 4.2+. */ defined('MOODLE_INTERNAL') || die(); debugging('This file is no longer required in Moodle 4.2+. Please do not include/require it.', DEBUG_DEVELOPER); PK9B\Bj\\ index.phpnu[. /** * This script lists all the instances of quiz in a particular course * * @package mod_quiz * @copyright 1999 onwards Martin Dougiamas {@link http://moodle.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ use mod_quiz\output\grades\grade_out_of; use mod_quiz\output\renderer; require_once("../../config.php"); require_once("locallib.php"); $id = required_param('id', PARAM_INT); $PAGE->set_url('/mod/quiz/index.php', ['id' => $id]); $course = get_course($id); $coursecontext = context_course::instance($id); require_login($course); $PAGE->set_pagelayout('incourse'); $params = [ 'context' => $coursecontext ]; $event = \mod_quiz\event\course_module_instance_list_viewed::create($params); $event->trigger(); // Print the header. $strquizzes = get_string("modulenameplural", "quiz"); $PAGE->navbar->add($strquizzes); $PAGE->set_title($strquizzes); $PAGE->set_heading($course->fullname); /** @var renderer $output */ $output = $PAGE->get_renderer('mod_quiz'); echo $output->header(); echo $output->heading($strquizzes, 2); // Get all the appropriate data. if (!$quizzes = get_all_instances_in_course("quiz", $course)) { notice(get_string('thereareno', 'moodle', $strquizzes), "../../course/view.php?id=$course->id"); die; } // Check if we need the feedback header. $showfeedback = false; foreach ($quizzes as $quiz) { if (quiz_has_feedback($quiz)) { $showfeedback=true; } if ($showfeedback) { break; } } // Configure table for displaying the list of instances. $headings = [get_string('name')]; $align = ['left']; array_push($headings, get_string('quizcloses', 'quiz')); array_push($align, 'left'); if (course_format_uses_sections($course->format)) { array_unshift($headings, course_get_format($course)->get_generic_section_name()); } else { array_unshift($headings, ''); } array_unshift($align, 'center'); $showing = ''; if (has_capability('mod/quiz:viewreports', $coursecontext)) { array_push($headings, get_string('attempts', 'quiz')); array_push($align, 'left'); $showing = 'stats'; } else if (has_any_capability(['mod/quiz:reviewmyattempts', 'mod/quiz:attempt'], $coursecontext)) { array_push($headings, get_string('gradenoun')); array_push($align, 'left'); if ($showfeedback) { array_push($headings, get_string('feedback', 'quiz')); array_push($align, 'left'); } $showing = 'grades'; $grades = $DB->get_records_sql_menu(' SELECT qg.quiz, qg.grade FROM {quiz_grades} qg JOIN {quiz} q ON q.id = qg.quiz WHERE q.course = ? AND qg.userid = ?', [$course->id, $USER->id]); } $table = new html_table(); $table->head = $headings; $table->align = $align; // Populate the table with the list of instances. $currentsection = ''; // Get all closing dates. $timeclosedates = quiz_get_user_timeclose($course->id); foreach ($quizzes as $quiz) { $cm = get_coursemodule_from_instance('quiz', $quiz->id); $context = context_module::instance($cm->id); $data = []; // Section number if necessary. $strsection = ''; if ($quiz->section != $currentsection) { if ($quiz->section) { $strsection = $quiz->section; $strsection = get_section_name($course, $quiz->section); } if ($currentsection !== "") { $table->data[] = 'hr'; } $currentsection = $quiz->section; } $data[] = $strsection; // Link to the instance. $class = ''; if (!$quiz->visible) { $class = ' class="dimmed"'; } $data[] = "coursemodule\">" . format_string($quiz->name, true) . ''; // Close date. if (($timeclosedates[$quiz->id]->usertimeclose != 0)) { $data[] = userdate($timeclosedates[$quiz->id]->usertimeclose); } else { $data[] = get_string('noclose', 'quiz'); } if ($showing == 'stats') { // The $quiz objects returned by get_all_instances_in_course have the necessary $cm // fields set to make the following call work. $data[] = quiz_attempt_summary_link_to_reports($quiz, $cm, $context); } else if ($showing == 'grades') { // Grade and feedback. $attempts = quiz_get_user_attempts($quiz->id, $USER->id, 'all'); list($someoptions, $alloptions) = quiz_get_combined_reviewoptions( $quiz, $attempts); $grade = ''; $feedback = ''; if ($quiz->grade && array_key_exists($quiz->id, $grades)) { if ($alloptions->marks >= question_display_options::MARK_AND_MAX) { $grade = $output->render(new grade_out_of( $quiz, $grades[$quiz->id], $quiz->grade, $quiz->sumgrades, style: grade_out_of::SHORT)); } if ($alloptions->overallfeedback) { $feedback = quiz_feedback_for_grade($grades[$quiz->id], $quiz, $context); } } $data[] = $grade; if ($showfeedback) { $data[] = $feedback; } } $table->data[] = $data; } // End of loop over quiz instances. // Display the table. echo html_writer::table($table); // Finish the page. echo $output->footer(); PK9B\%zrepaginate.phpnu[. /** * Rest endpoint for ajax editing for paging operations on the quiz structure. * * @package mod_quiz * @copyright 2014 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ use mod_quiz\quiz_settings; require_once(__DIR__ . '/../../config.php'); require_once($CFG->dirroot . '/mod/quiz/locallib.php'); $quizid = required_param('quizid', PARAM_INT); $slotnumber = required_param('slot', PARAM_INT); $repagtype = required_param('repag', PARAM_INT); require_sesskey(); $quizobj = quiz_settings::create($quizid); require_login($quizobj->get_course(), false, $quizobj->get_cm()); require_capability('mod/quiz:manage', $quizobj->get_context()); if (quiz_has_attempts($quizid)) { $reportlink = quiz_attempt_summary_link_to_reports($quizobj->get_quiz(), $quizobj->get_cm(), $quizobj->get_context()); throw new \moodle_exception('cannoteditafterattempts', 'quiz', new moodle_url('/mod/quiz/edit.php', ['cmid' => $quizobj->get_cmid()]), $reportlink); } $slotnumber++; $repage = new \mod_quiz\repaginate($quizid); $repage->repaginate_slots($slotnumber, $repagtype); $structure = $quizobj->get_structure(); $slots = $structure->refresh_page_numbers_and_update_db(); redirect(new moodle_url('edit.php', ['cmid' => $quizobj->get_cmid()])); PK9B\  "amd/src/submission_confirmation.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 submission confirmation for quiz. * * @module mod_quiz/submission_confirmation * @copyright 2022 Huong Nguyen * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @since 4.1 */ import {saveCancelPromise} from 'core/notification'; import Prefetch from 'core/prefetch'; import Templates from 'core/templates'; import {getString} from 'core/str'; const SELECTOR = { attemptSubmitButton: '.path-mod-quiz .btn-finishattempt button', attemptSubmitForm: 'form#frm-finishattempt', }; const TEMPLATES = { submissionConfirmation: 'mod_quiz/submission_confirmation', }; /** * Register events for attempt submit button. * @param {int} unAnsweredQuestions Total number of un-answered questions */ const registerEventListeners = (unAnsweredQuestions) => { const submitAction = document.querySelector(SELECTOR.attemptSubmitButton); if (submitAction) { submitAction.addEventListener('click', async(e) => { e.preventDefault(); try { await saveCancelPromise( getString('submission_confirmation', 'quiz'), Templates.render(TEMPLATES.submissionConfirmation, { hasunanswered: unAnsweredQuestions > 0, totalunanswered: unAnsweredQuestions }), getString('submitallandfinish', 'quiz') ); // Save pressed. submitAction.closest(SELECTOR.attemptSubmitForm).submit(); } catch { // Cancel pressed. return; } }); } }; /** * Initialises. * @param {int} unAnsweredQuestions Total number of unanswered questions */ export const init = (unAnsweredQuestions) => { Prefetch.prefetchStrings('core', ['submit']); Prefetch.prefetchStrings('core_admin', ['confirmation']); Prefetch.prefetchStrings('quiz', ['submitallandfinish', 'submission_confirmation']); Prefetch.prefetchTemplate(TEMPLATES.submissionConfirmation); registerEventListeners(unAnsweredQuestions); }; PK9B\cގamd/src/preflightcheck.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 manages the confirmation pop-up (also called the pre-flight check) * that is sometimes shown when a use clicks the start attempt button. * * This is also responsible for opening the pop-up window, if the quiz requires to be in one. * * @module mod_quiz/preflightcheck * @copyright 2016 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @since 3.1 */ define(['jquery', 'core/yui', 'core_form/changechecker'], function($, Y, FormChangeChecker) { /** * @alias module:mod_quiz/preflightcheck */ var t = { confirmDialogue: null, /** * Initialise the start attempt button. * * @param {String} startButton the id of the start attempt button that we will be enhancing. * @param {String} confirmationTitle the title of the dialogue. * @param {String} confirmationForm selector for the confirmation form to show in the dialogue. * @param {String} popupoptions If not null, the quiz should be launced in a pop-up. */ init: function(startButton, confirmationTitle, confirmationForm, popupoptions) { var finalStartButton = startButton; Y.use('moodle-core-notification', function() { if (Y.one(confirmationForm)) { t.confirmDialogue = new M.core.dialogue({ headerContent: confirmationTitle, bodyContent: Y.one(confirmationForm), draggable: true, visible: false, center: true, modal: true, width: null, extraClasses: ['mod_quiz_preflight_popup'] }); Y.one(startButton).on('click', t.displayDialogue); Y.one('#id_cancel').on('click', t.hideDialogue); finalStartButton = t.confirmDialogue.get('boundingBox').one('[name="submitbutton"]'); } if (popupoptions) { Y.one(finalStartButton).on('click', t.launchQuizPopup, t, popupoptions); } }); }, /** * Display the dialogue. * @param {Y.EventFacade} e the event being responded to, if any. */ displayDialogue: function(e) { if (e) { e.halt(); } t.confirmDialogue.show(); }, /** * Hide the dialogue. * @param {Y.EventFacade} e the event being responded to, if any. */ hideDialogue: function(e) { if (e) { e.halt(); } t.confirmDialogue.hide(e); }, /** * Event handler for the quiz start attempt button. * @param {Event} e the event being responded to * @param {Object} popupoptions */ launchQuizPopup: function(e, popupoptions) { e.halt(); Y.use('io-form', function() { var form = e.target.ancestor('form'); FormChangeChecker.resetFormDirtyState(form.getDOMNode()); window.openpopup(e, { url: form.get('action') + '?' + Y.IO.stringify(form).replace(/\bcancel=/, 'x='), windowname: 'quizpopup', options: popupoptions, fullscreen: true, }); }); } }; return t; }); PK9B\43##'amd/src/random_question_form_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 the random_question_form_preview of the * add_random_form class. * * @module mod_quiz/random_question_form_preview * @copyright 2018 Ryan Wyllie * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ define( [ 'jquery', 'core/ajax', 'core/str', 'core/notification', 'core/templates', 'core/paged_content_factory' ], function( $, Ajax, Str, Notification, Templates, PagedContentFactory ) { var ITEMS_PER_PAGE = 5; var TEMPLATE_NAME = 'mod_quiz/random_question_form_preview_question_list'; var SELECTORS = { LOADING_ICON_CONTAINER: '[data-region="overlay-icon-container"]', QUESTION_COUNT_CONTAINER: '[data-region="question-count-container"]', QUESTION_LIST_CONTAINER: '[data-region="question-list-container"]' }; /** * Show the loading spinner over the preview section. * * @param {jquery} root The root element. */ var showLoadingIcon = function(root) { root.find(SELECTORS.LOADING_ICON_CONTAINER).removeClass('hidden'); }; /** * Hide the loading spinner. * * @param {jquery} root The root element. */ var hideLoadingIcon = function(root) { root.find(SELECTORS.LOADING_ICON_CONTAINER).addClass('hidden'); }; /** * Render the section of text to show the question count. * * @param {jquery} root The root element. * @param {int} questionCount The number of questions. */ var renderQuestionCount = function(root, questionCount) { Str.get_string('questionsmatchingfilter', 'mod_quiz', questionCount) .then(function(string) { root.find(SELECTORS.QUESTION_COUNT_CONTAINER).html(string); return; }) .fail(Notification.exception); }; /** * Send a request to the server for more questions. * * @param {int} categoryId A question category id. * @param {bool} includeSubcategories If the results should include subcategory questions * @param {int[]} tagIds The list of tag ids that each question must have. * @param {int} contextId The context where the questions will be added. * @param {int} limit How many questions to retrieve. * @param {int} offset How many questions to skip from the start of the result set. * @return {promise} Resolved when the preview section has rendered. */ var requestQuestions = function( categoryId, includeSubcategories, tagIds, contextId, limit, offset ) { var request = { methodname: 'core_question_get_random_question_summaries', args: { categoryid: categoryId, includesubcategories: includeSubcategories, tagids: tagIds, contextid: contextId, limit: limit, offset: offset } }; return Ajax.call([request])[0]; }; /** * Build a paged content widget for questions with the given criteria. The * criteria is used to fetch more questions from the server as the user * requests new pages. * * @param {int} categoryId A question category id. * @param {bool} includeSubcategories If the results should include subcategory questions * @param {int[]} tagIds The list of tag ids that each question must have. * @param {int} contextId The context where the questions will be added. * @param {int} totalQuestionCount How many questions match the criteria above. * @param {object[]} firstPageQuestions List of questions for the first page. * @return {promise} A promise resolved with the HTML and JS for the paged content. */ var renderQuestionsAsPagedContent = function( categoryId, includeSubcategories, tagIds, contextId, totalQuestionCount, firstPageQuestions ) { // Provide a callback, renderQuestionsPages, // to control how the questions on each page are rendered. return PagedContentFactory.createFromAjax( totalQuestionCount, ITEMS_PER_PAGE, // Callback function to render the requested pages. function(pagesData) { return pagesData.map(function(pageData) { var limit = pageData.limit; var offset = pageData.offset; if (offset == 0) { // The first page is being requested and we've already got // that data so we can just render it immediately. return Templates.render(TEMPLATE_NAME, {questions: firstPageQuestions}); } else { // Otherwise we need to ask the server for the data. return requestQuestions( categoryId, includeSubcategories, tagIds, contextId, limit, offset ) .then(function(response) { var questions = response.questions; return Templates.render(TEMPLATE_NAME, {questions: questions}); }) .fail(Notification.exception); } }); } ); }; /** * Re-render the preview section based on the provided filter criteria. * * @param {jquery} root The root element. * @param {int} categoryId A question category id. * @param {bool} includeSubcategories If the results should include subcategory questions * @param {int[]} tagIds The list of tag ids that each question must have. * @param {int} contextId The context where the questions will be added. * @return {promise} Resolved when the preview section has rendered. */ var reload = function(root, categoryId, includeSubcategories, tagIds, contextId) { // Show the loading spinner to tell the user that something is happening. showLoadingIcon(root); // Load the first set of questions. return requestQuestions(categoryId, includeSubcategories, tagIds, contextId, ITEMS_PER_PAGE, 0) .then(function(response) { var totalCount = response.totalcount; // Show the help message for the user to indicate how many questions // match their filter criteria. renderQuestionCount(root, totalCount); return response; }) .then(function(response) { var totalQuestionCount = response.totalcount; var questions = response.questions; if (questions.length) { // We received some questions so render them as paged content // with a paging bar. return renderQuestionsAsPagedContent( categoryId, includeSubcategories, tagIds, contextId, totalQuestionCount, questions ); } else { // If we didn't receive any questions then we can return empty // HTML and JS to clear the preview section. return $.Deferred().resolve('', ''); } }) .then(function(html, js) { // Show the user the question set. var container = root.find(SELECTORS.QUESTION_LIST_CONTAINER); Templates.replaceNodeContents(container, html, js); return; }) .always(function() { hideLoadingIcon(root); }) .fail(Notification.exception); }; return { reload: reload, showLoadingIcon: showLoadingIcon, hideLoadingIcon: hideLoadingIcon }; }); PK9B\ 2amd/src/update_random_question_filter_condition.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 . /** * Event handling for the edit random question form. * * Dynamically saves the new filter condition before navigating back to the quiz question list. * * @module mod_quiz/update_random_question_filter_condition * @author 2022 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ import ajax from 'core/ajax'; import Notification from 'core/notification'; export const init = () => { const SELECTORS = { QUESTION_BANK_CONTAINER: '#questionbank_container', FORM_ELEMENT: '#update_filter_condition_form', UPDATE_BUTTON: '[name="update"]', CANCEL_BUTTON: '[name="cancel"]', MESSAGE_INPUT: '[name="message"]', FILTER_CONDITION_ELEMENT: '[data-filtercondition]', }; const questionBank = document.querySelector(SELECTORS.QUESTION_BANK_CONTAINER); const form = document.querySelector(SELECTORS.FORM_ELEMENT); const updateButton = form.querySelector(SELECTORS.UPDATE_BUTTON); updateButton.addEventListener("click", async(e) => { e.preventDefault(); const request = { methodname: 'mod_quiz_update_filter_condition', args: { cmid: form.dataset?.cmid, slotid: form.dataset?.slotid, filtercondition: questionBank.querySelector(SELECTORS.FILTER_CONDITION_ELEMENT).dataset?.filtercondition, } }; try { const response = await ajax.call([request])[0]; const messageInput = form.querySelector(SELECTORS.MESSAGE_INPUT); messageInput.value = response.message; form.submit(); } catch (e) { Notification.exception(e); } }); }; PK9B\3jjamd/src/repaginate.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 . /** * Initialise the repaginate dialogue on quiz editing page. * * @module mod_quiz/repaginate * @copyright 2019 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ import Modal from 'core/modal'; export const init = () => { document.addEventListener('click', (event) => { const repaginateCommand = event.target.closest('#repaginatecommand'); if (!repaginateCommand) { return; } event.preventDefault(); Modal.create({ title: repaginateCommand.dataset.header, body: repaginateCommand.dataset.form, large: false, show: true, }); }); }; PK9B\TTamd/src/question_slot.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 . /** * Render the question slot template for each question in the quiz edit view. * * @module mod_quiz/question_slot * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Guillermo Gomez Arias * @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'; /** * Set the question version for the slot. * * @param {Number} slotId * @param {Number} newVersion * @return {Array} The modified question version */ const setQuestionVersion = (slotId, newVersion) => fetchMany([{ methodname: 'mod_quiz_set_question_version', args: { slotid: slotId, newversion: newVersion, } }])[0]; /** * Replace the container with a new version. */ const registerEventListeners = () => { document.addEventListener('change', e => { if (!e.target.matches('[data-action="mod_quiz-select_slot"][data-slot-id]')) { return; } const slotId = e.target.dataset.slotId; const newVersion = parseInt(e.target.value); setQuestionVersion(slotId, newVersion === 0 ? null : newVersion) .then(() => { location.reload(); return; }) .catch(Notification.exception); }); }; /** * Entrypoint of the js. */ export const init = () => { registerEventListeners(); }; PK9B\``amd/src/add_random_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 . /** * JavaScript for the add_random_form class. * * @module mod_quiz/add_random_form * @copyright 2018 Ryan Wyllie * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ define( [ 'jquery', 'mod_quiz/random_question_form_preview' ], function( $, RandomQuestionFormPreview ) { // Wait 2 seconds before reloading the question set just in case // the user is still changing the criteria. var RELOAD_DELAY = 2000; var SELECTORS = { PREVIEW_CONTAINER: '[data-region="random-question-preview-container"]', CATEGORY_FORM_ELEMENT: '[name="category"]', SUBCATEGORY_FORM_ELEMENT: '[name="includesubcategories"]', TAG_IDS_FORM_ELEMENT: '[name="fromtags[]"]' }; /** * Get the selected category value from the form. * * @param {jquery} form The form element. * @return {string} The category value. */ var getCategorySelectValue = function(form) { return form.find(SELECTORS.CATEGORY_FORM_ELEMENT).val(); }; /** * Get the category id from the form. * * @param {jquery} form The form element. * @return {string} The category id. */ var getCategoryId = function(form) { // The value string is the category id and category context id joined // by a comma. var valueString = getCategorySelectValue(form); // Split the two ids. var values = valueString.split(','); // Return just the category id. return values[0]; }; /** * Check if a top level category is selected in the form. * * @param {jquery} form The form element. * @param {string[]} topCategories List of top category values (matching the select box values) * @return {bool} */ var isTopLevelCategorySelected = function(form, topCategories) { var selectedValue = getCategorySelectValue(form); return (topCategories.indexOf(selectedValue) > -1); }; /** * Check if the form indicates we should include include subcategories in * the filter. * * @param {jquery} form The form element. * @param {string[]} topCategories List of top category values (matching the select box values) * @return {bool} */ var shouldIncludeSubcategories = function(form, topCategories) { if (isTopLevelCategorySelected(form, topCategories)) { return true; } else { return form.find(SELECTORS.SUBCATEGORY_FORM_ELEMENT).is(':checked'); } }; /** * Get the tag ids for the selected tags in the form. * * @param {jquery} form The form element. * @return {string[]} The tag ids. */ var getTagIds = function(form) { var values = form.find(SELECTORS.TAG_IDS_FORM_ELEMENT).val(); return values.map(function(value) { // The tag element value is the tag id and tag name joined // by a comma. So we need to split them to get the tag id. var parts = value.split(','); return parts[0]; }); }; /** * Reload the preview section with a new set of filters. * * @param {jquery} form The form element. * @param {int} contextId The current context id. * @param {string[]} topCategories List of top category values (matching the select box values) */ var reloadQuestionPreview = function(form, contextId, topCategories) { var previewContainer = form.find(SELECTORS.PREVIEW_CONTAINER); RandomQuestionFormPreview.reload( previewContainer, getCategoryId(form), shouldIncludeSubcategories(form, topCategories), getTagIds(form), contextId ); }; /** * Is this an element we're interested in listening to changes on. * * @param {jquery} element The element to check. * @return {bool} */ var isInterestingElement = function(element) { if (element.closest(SELECTORS.CATEGORY_FORM_ELEMENT).length > 0) { return true; } if (element.closest(SELECTORS.SUBCATEGORY_FORM_ELEMENT).length > 0) { return true; } if (element.closest(SELECTORS.TAG_IDS_FORM_ELEMENT).length > 0) { return true; } return false; }; /** * Listen for changes to any of the interesting elements and reload the form * preview with the new filter values if they are changed. * * The reload is delayed for a small amount of time (see RELOAD_DELAY) in case * the user is actively editing the form. This allows us to avoid having to * send multiple requests to the server on each change. * * Instead we can just send a single request when the user appears to have * finished editing the form. * * @param {jquery} form The form element. * @param {int} contextId The current context id. * @param {string[]} topCategories List of top category values (matching the select box values) */ var addEventListeners = function(form, contextId, topCategories) { var reloadTimerId = null; const tagsFilter = form.find(SELECTORS.TAG_IDS_FORM_ELEMENT); form.add(tagsFilter).on('change', function(e) { // Only reload the preview when elements that will change the result // are modified. if (!isInterestingElement($(e.target))) { return; } // Show the loading icon to let the user know that the preview // will be updated after their actions. RandomQuestionFormPreview.showLoadingIcon(form); if (reloadTimerId) { // Reset the timer each time the form is modified. clearTimeout(reloadTimerId); } // Don't immediately reload the question preview section just // in case the user is still modifying the form. We don't want to // spam reload requests. reloadTimerId = setTimeout(function() { reloadQuestionPreview(form, contextId, topCategories); }, RELOAD_DELAY); }); }; /** * Trigger the first load of the preview section and then listen for modifications * to the form to reload the preview with new filter values. * * @param {jquery} formId The form element id. * @param {int} contextId The current context id. * @param {string[]} topCategories List of top category values (matching the select box values) * @param {bool} isTagsEnabled Whether tags feature is enabled or not. */ var init = function(formId, contextId, topCategories, isTagsEnabled) { if (isTagsEnabled == true) { var form = $('#' + formId); reloadQuestionPreview(form, contextId, topCategories, isTagsEnabled); addEventListeners(form, contextId, topCategories, isTagsEnabled); } }; return { init: init }; }); PK9B\~$@$@amd/src/edit_multiple_grades.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 managing multiple grade items for a quiz. * * @module mod_quiz/edit_multiple_grades * @copyright 2023 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ import {call as fetchMany} from 'core/ajax'; import MoodleConfig from 'core/config'; import {addIconToContainer} from 'core/loadingicon'; import Notification from 'core/notification'; import Pending from 'core/pending'; import {get_string as getString} from 'core/str'; import {render as renderTemplate} from 'core/templates'; import {replaceNode} from 'core/templates'; /** * @type {Object} selectors used in this code. */ const SELECTORS = { 'addGradeItemButton': '#mod_quiz-add_grade_item', 'autoSetupButton': '#mod_quiz-grades_auto_setup', 'editingPageContents': '#edit_grading_page-contents', 'gradeItemList': 'table#mod_quiz-grade-item-list', 'gradeItemSelect': 'select[data-slot-id]', 'gradeItemSelectId': (id) => 'select#grade-item-choice-' + id, 'gradeItemTr': 'table#mod_quiz-grade-item-list tr[data-quiz-grade-item-id]', 'inplaceEditable': 'span.inplaceeditable', 'inplaceEditableOn': 'span.inplaceeditable.inplaceeditingon', 'resetAllButton': '#mod_quiz-grades_reset_all', 'slotList': 'table#mod_quiz-slot-list', 'updateGradeItemLink': (id) => 'tr[data-quiz-grade-item-id="' + id + '"] .quickeditlink', }; /** * Call the Ajax service to create a quiz grade item. * * @param {Number} quizId id of the quiz to update. * @returns {Promise} a promise that resolves to the template context required to re-render the page. */ const createGradeItem = ( quizId, ) => callServiceAndReturnRenderingData({ methodname: 'mod_quiz_create_grade_items', args: { quizid: quizId, quizgradeitems: [{name: ''}], } }); /** * Call the Ajax service to update a quiz grade item. * * @param {Number} quizId id of the quiz to update. * @param {Number} gradeItemId id of the grade item to update. * @param {String} newName the new name to set. * @return {Promise} Promise that resolves to the context required to re-render the page. */ const updateGradeItem = ( quizId, gradeItemId, newName ) => callServiceAndReturnRenderingData({ methodname: 'mod_quiz_update_grade_items', args: { quizid: quizId, quizgradeitems: [{id: gradeItemId, name: newName}], } }); /** * Call the Ajax service to delete a quiz grade item. * * @param {Number} quizId id of the quiz to update. * @param {Number} gradeItemId id of the grade item to delete. * @return {Promise} Promise that resolves to the context required to re-render the page. */ const deleteGradeItem = ( quizId, gradeItemId ) => callServiceAndReturnRenderingData({ methodname: 'mod_quiz_delete_grade_items', args: { quizid: quizId, quizgradeitems: [{id: gradeItemId}], } }); /** * Call the Ajax service to update the quiz grade item used by a slot. * * @param {Number} quizId id of the quiz to update. * @param {Number} slotId id of the slot to update. * @param {Number|null} gradeItemId new grade item ot set, or null to un-set. * @return {Promise} Promise that resolves to the context required to re-render the page. */ const updateSlotGradeItem = ( quizId, slotId, gradeItemId ) => callServiceAndReturnRenderingData({ methodname: 'mod_quiz_update_slots', args: { quizid: quizId, slots: [{id: slotId, quizgradeitemid: gradeItemId}], } }); /** * Call the Ajax service to setup one grade item for each quiz section. * * @param {Number} quizId id of the quiz to update. * @return {Promise} Promise that resolves to the context required to re-render the page. */ const autoSetupGradeItems = ( quizId ) => callServiceAndReturnRenderingData({ methodname: 'mod_quiz_create_grade_item_per_section', args: { quizid: quizId } }); /** * Make a web service call, and also call mod_quiz_get_edit_grading_page_data to get the date to re-render the page. * * @param {Object} methodCall a web service call to pass to fetchMany. Must include methodCall.args.quizid. * @returns {Promise} a promise that resolves to the template context required to re-render the page. */ const callServiceAndReturnRenderingData = (methodCall) => callServicesAndReturnRenderingData([methodCall]); /** * Make a web service call, and also call mod_quiz_get_edit_grading_page_data to get the date to re-render the page. * * @param {Object[]} methodCalls web service calls to pass to fetchMany. Must include methodCalls[0].args.quizid. * @returns {Promise} a promise that resolves to the template context required to re-render the page. */ const callServicesAndReturnRenderingData = (methodCalls) => { methodCalls.push({ methodname: 'mod_quiz_get_edit_grading_page_data', args: { quizid: methodCalls[0].args.quizid, } }); return Promise.all(fetchMany(methodCalls)) .then(results => JSON.parse(results.at(-1))); }; /** * Handle click events on the delete icon. * * @param {Event} e click event. */ const handleGradeItemDelete = (e) => { e.preventDefault(); const pending = new Pending('delete-quiz-grade-item'); const tableCell = e.target.closest('td'); addIconToContainer(tableCell, pending); const tableRow = tableCell.closest('tr'); const quizId = tableRow.closest('table').dataset.quizId; const gradeItemId = tableRow.dataset.quizGradeItemId; let nextItemToFocus; if (tableRow.nextElementSibling) { nextItemToFocus = SELECTORS.updateGradeItemLink(tableRow.nextElementSibling.dataset.quizGradeItemId); } else { nextItemToFocus = SELECTORS.addGradeItemButton; } deleteGradeItem(quizId, gradeItemId) .then(reRenderPage) .then(() => { pending.resolve(); document.querySelector(nextItemToFocus).focus(); }) .catch(Notification.exception); }; /** * * @param {HTMLElement} editableSpan the editable to turn off. */ const stopEditingGadeItem = (editableSpan) => { editableSpan.innerHTML = editableSpan.dataset.oldContent; delete editableSpan.dataset.oldContent; editableSpan.classList.remove('inplaceeditingon'); editableSpan.querySelector('[data-action-edit]').focus(); }; /** * Handle click events on the start rename icon. * * @param {Event} e click event. */ const handleGradeItemEditStart = (e) => { e.preventDefault(); const pending = new Pending('edit-quiz-grade-item-start'); const editableSpan = e.target.closest(SELECTORS.inplaceEditable); document.querySelectorAll(SELECTORS.inplaceEditableOn).forEach(stopEditingGadeItem); editableSpan.dataset.oldContent = editableSpan.innerHTML; getString('edittitleinstructions') .then((instructions) => { const uniqueId = 'gi-edit-input-' + editableSpan.closest('tr').dataset.quizGradeItemId; editableSpan.innerHTML = '' + instructions + '' + '' + ''; const inputElement = editableSpan.querySelector('input'); inputElement.focus(); inputElement.select(); editableSpan.classList.add('inplaceeditingon'); pending.resolve(); return null; }) .catch(Notification.exception); }; /** * Handle key down in the editable. * * @param {Event} e key event. */ const handleGradeItemKeyDown = (e) => { if (e.keyCode !== 13) { return; } const editableSpan = e.target.closest(SELECTORS.inplaceEditableOn); // Check this click is on a relevant element. if (!editableSpan || !editableSpan.closest(SELECTORS.gradeItemList)) { return; } e.preventDefault(); const pending = new Pending('edit-quiz-grade-item-save'); const newName = editableSpan.querySelector('input').value; const tableCell = e.target.closest('th'); addIconToContainer(tableCell); const tableRow = tableCell.closest('tr'); const quizId = tableRow.closest('table').dataset.quizId; const gradeItemId = tableRow.dataset.quizGradeItemId; updateGradeItem(quizId, gradeItemId, newName) .then(reRenderPage) .then(() => { pending.resolve(); document.querySelector(SELECTORS.updateGradeItemLink(gradeItemId)).focus({'focusVisible': true}); }) .catch(Notification.exception); }; /** * Replace the contents of the page with the page re-rendered from the provided data, once that promise resolves. * * @param {Object} editGradingPageData the template context data required to re-render the page. * @returns {Promise} a promise that will resolve when the page is updated. */ const reRenderPage = (editGradingPageData) => renderTemplate('mod_quiz/edit_grading_page', editGradingPageData) .then((html, js) => replaceNode(document.querySelector(SELECTORS.editingPageContents), html, js || '')); /** * Handle key up in the editable. * * @param {Event} e key event. */ const handleGradeItemKeyUp = (e) => { if (e.keyCode !== 27) { return; } const editableSpan = e.target.closest(SELECTORS.inplaceEditableOn); // Check this click is on a relevant element. if (!editableSpan || !editableSpan.closest(SELECTORS.gradeItemList)) { return; } e.preventDefault(); stopEditingGadeItem(editableSpan); }; /** * Handle focus out of the editable. * * @param {Event} e event. */ const handleGradeItemFocusOut = (e) => { if (MoodleConfig.behatsiterunning) { // Behat triggers focusout too often so ignore. return; } const editableSpan = e.target.closest(SELECTORS.inplaceEditableOn); // Check this click is on a relevant element. if (!editableSpan || !editableSpan.closest(SELECTORS.gradeItemList)) { return; } e.preventDefault(); stopEditingGadeItem(editableSpan); }; /** * Handle when the selected grade item for a slot is changed. * * @param {Event} e event. */ const handleSlotGradeItemChanged = (e) => { const select = e.target.closest(SELECTORS.gradeItemSelect); // Check this click is on a relevant element. if (!select || !select.closest(SELECTORS.slotList)) { return; } e.preventDefault(); const pending = new Pending('edit-slot-grade-item-updated'); const slotId = select.dataset.slotId; const newGradeItemId = select.value ? select.value : null; const tableCell = e.target.closest('td'); addIconToContainer(tableCell, pending); const quizId = tableCell.closest('table').dataset.quizId; updateSlotGradeItem(quizId, slotId, newGradeItemId) .then(reRenderPage) .then(() => { pending.resolve(); document.querySelector(SELECTORS.gradeItemSelectId(slotId)).focus(); }) .catch(Notification.exception); }; /** * Handle clicks in the table the shows the grade items. * * @param {Event} e click event. */ const handleGradeItemClick = (e) => { const link = e.target.closest('a'); // Check this click is on a relevant element. if (!link || !link.closest(SELECTORS.gradeItemList)) { return; } if (link.dataset.actionDelete) { handleGradeItemDelete(e); } if (link.dataset.actionEdit) { handleGradeItemEditStart(e); } }; /** * Handle clicks on the buttons. * * @param {Event} e click event. */ const handleButtonClick = (e) => { if (e.target.closest(SELECTORS.addGradeItemButton)) { handleAddGradeItemClick(e); } if (e.target.closest(SELECTORS.autoSetupButton)) { handleAutoSetup(e); } if (e.target.closest(SELECTORS.resetAllButton)) { handleResetAllClick(e); } }; /** * Handle clicks on the 'Add grade item' button. * * @param {Event} e click event. */ const handleAddGradeItemClick = (e) => { e.preventDefault(); const pending = new Pending('create-quiz-grade-item'); addIconToContainer(e.target.parentNode, pending); const quizId = e.target.dataset.quizId; createGradeItem(quizId) .then(reRenderPage) .then(() => { pending.resolve(); document.querySelector(SELECTORS.addGradeItemButton).focus(); }) .catch(Notification.exception); }; /** * Handle clicks on the reset button - show a confirmation. * * @param {Event} e click event. */ const handleAutoSetup = (e) => { e.preventDefault(); const pending = new Pending('setup-quiz-grade-items'); const quizId = e.target.dataset.quizId; autoSetupGradeItems(quizId) .then(reRenderPage) .then(() => { pending.resolve(); document.querySelector(SELECTORS.resetAllButton).focus(); }) .catch(Notification.exception); }; /** * Handle clicks on the reset button - show a confirmation. * * @param {Event} e click event. */ const handleResetAllClick = (e) => { e.preventDefault(); const button = e.target; Notification.deleteCancelPromise( getString('gradeitemsremoveallconfirm', 'quiz'), getString('gradeitemsremoveallmessage', 'quiz'), getString('reset'), button ).then(() => reallyResetAll(button)) .catch(() => button.focus()); }; /** * Really reset all if the confirmation is OKed. * * @param {HTMLElement} button the reset button. */ const reallyResetAll = (button) => { const pending = new Pending('reset-quiz-grading'); addIconToContainer(button.parentNode, pending); const quizId = button.dataset.quizId; let methodCalls = []; // Call to clear any assignments of grade items to slots (if required). const slotResets = [...document.querySelectorAll(SELECTORS.gradeItemSelect)].map( (select) => ({ id: select.dataset.slotId, quizgradeitemid: 0, })); if (slotResets.length) { methodCalls.push({ methodname: 'mod_quiz_update_slots', args: { quizid: quizId, slots: slotResets } }); } // Request to delete all the grade items. methodCalls.push({ methodname: 'mod_quiz_delete_grade_items', args: { quizid: quizId, quizgradeitems: [...document.querySelectorAll(SELECTORS.gradeItemTr)].map((tr) => { return {id: tr.dataset.quizGradeItemId}; }) } }); callServicesAndReturnRenderingData(methodCalls) .then(reRenderPage) .then(() => { pending.resolve(); document.querySelector(SELECTORS.addGradeItemButton).focus(); }) .catch(Notification.exception); }; /** * Replace the container with a new version. */ const registerEventListeners = () => { document.body.addEventListener('click', handleGradeItemClick); document.body.addEventListener('keydown', handleGradeItemKeyDown); document.body.addEventListener('keyup', handleGradeItemKeyUp); document.body.addEventListener('focusout', handleGradeItemFocusOut); document.body.addEventListener('click', handleButtonClick); document.body.addEventListener('change', handleSlotGradeItemChanged); }; /** * Entry point. */ export const init = () => { registerEventListeners(); }; PK9B\Eamd/src/add_question_modal.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 add random question modal. * * @module mod_quiz/add_question_modal * @copyright 2023 Andrew Lyons * @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'; export default class AddQuestionModal extends Modal { configure(modalConfig) { // Add question modals are always large. modalConfig.large = true; // Always show on creation. modalConfig.show = true; modalConfig.removeOnClose = true; // Apply question modal configuration. this.setContextId(modalConfig.contextId); this.setAddOnPageId(modalConfig.addOnPage); // Store the quiz module id for when we need to POST to the quiz. // This is because the URL cmid param will change during filter operations as we will be in another bank context. this.quizCmId = modalConfig.quizCmId; this.bankCmId = modalConfig.bankCmId; // Store the original title of the modal, so we can revert back to it once we have switched to another bank. this.originalTitle = modalConfig.title; // Apply standard configuration. super.configure(modalConfig); } constructor(root) { super(root); this.contextId = null; this.addOnPageId = null; } /** * Save the Moodle context id that the question bank is being * rendered in. * * @method setContextId * @param {Number} id */ setContextId(id) { this.contextId = id; } /** * Retrieve the saved Moodle context id. * * @method getContextId * @return {Number} */ getContextId() { return this.contextId; } /** * Set the id of the page that the question should be added to * when the user clicks the add to quiz link. * * @method setAddOnPageId * @param {Number} id */ setAddOnPageId(id) { this.addOnPageId = id; } /** * Returns the saved page id for the question to be added to. * * @method getAddOnPageId * @return {Number} */ getAddOnPageId() { return this.addOnPageId; } /** * Update the modal with a list of banks to switch to and enhance the standard selects to Autocomplete fields. * * @param {String} Selector for the original select element. * @return {Promise} Modal. */ async handleSwitchBankContentReload(Selector) { this.setTitle(getString('selectquestionbank', 'mod_quiz')); // Create a 'Go back' button and set it in the footer. const el = document.createElement('button'); el.classList.add('btn', 'btn-primary'); el.textContent = await getString('gobacktoquiz', 'mod_quiz'); el.setAttribute('data-action', 'go-back'); el.setAttribute('value', this.bankCmId); this.setFooter(el); this.setBody( Fragment.loadFragment( 'mod_quiz', 'switch_question_bank', this.getContextId(), { 'quizcmid': this.quizCmId, 'bankcmid': this.bankCmId, }) ); const placeholder = await getString('searchbyname', 'mod_quiz'); await this.getBodyPromise(); await AutoComplete.enhance( Selector, false, 'core_question/question_banks_datasource', placeholder, false, true, '', true ); // Hide the selection element as we don't need it. document.querySelector('.search-banks .form-autocomplete-selection')?.classList.add('d-none'); return this; } } PK9B\(QQ$amd/src/modal_add_random_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 . /** * Contain the logic for the add random question modal. * * @module mod_quiz/modal_add_random_question * @copyright 2018 Ryan Wyllie * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ import $ from 'jquery'; import Modal from './add_question_modal'; import * as Notification from 'core/notification'; import * as Fragment from 'core/fragment'; import * as Templates from 'core/templates'; import * as FormChangeChecker from 'core_form/changechecker'; import {call as fetchMany} from 'core/ajax'; import Pending from 'core/pending'; const SELECTORS = { ANCHOR: 'a[href]', EXISTING_CATEGORY_CONTAINER: '[data-region="existing-category-container"]', EXISTING_CATEGORY_TAB: '#id_existingcategoryheader', NEW_CATEGORY_CONTAINER: '[data-region="new-category-container"]', NEW_CATEGORY_TAB: '#id_newcategoryheader', TAB_CONTENT: '[data-region="tab-content"]', ADD_ON_PAGE_FORM_ELEMENT: '[name="addonpage"]', ADD_RANDOM_BUTTON: 'input[type="submit"][name="addrandom"]', ADD_NEW_CATEGORY_BUTTON: 'input[type="submit"][name="newcategory"]', SUBMIT_BUTTON_ELEMENT: 'input[type="submit"][name="addrandom"], ' + 'input[type="submit"][name="newcategory"], ' + 'input[type="submit"][name="update"]', FORM_HEADER: 'legend', SELECT_NUMBER_TO_ADD: '#menurandomcount', NEW_CATEGORY_ELEMENT: '#categoryname', PARENT_CATEGORY_ELEMENT: '#parentcategory', FILTER_CONDITION_ELEMENT: '[data-filtercondition]', FORM_ELEMENT: '#add_random_question_form', MESSAGE_INPUT: '[name="message"]', SWITCH_TO_OTHER_BANK: 'button[data-action="switch-question-bank"]', NEW_BANKMOD_ID: 'data-newmodid', BANK_SEARCH: '#searchbanks', GO_BACK_BUTTON: 'button[data-action="go-back"]', UPDATE_FILTER_BUTTON: 'input[type="submit"][name="update"]', }; export default class ModalAddRandomQuestion extends Modal { static TYPE = 'mod_quiz-quiz-add-random-question'; static TEMPLATE = 'mod_quiz/modal_add_random_question'; /** * Create the add random question modal. * * @param {Number} contextId Current context id. * @param {Number} bankCmId Current question bank course module id. * @param {string} category Category id and category context id comma separated. * @param {string} returnUrl URL to return to after form submission. * @param {Number} quizCmId Current quiz course module id. * @param {boolean} showNewCategory Display the New category tab when selecting random questions. */ static init( contextId, bankCmId, category, returnUrl, quizCmId, showNewCategory = true ) { const selector = '.menu [data-action="addarandomquestion"], [data-action="editrandomquestion"]'; document.addEventListener('click', (e) => { const trigger = e.target.closest(selector); if (!trigger) { return; } e.preventDefault(); if (trigger.dataset.slotid) { showNewCategory = false; } ModalAddRandomQuestion.create({ contextId, bankCmId, category, returnUrl, quizCmId, showNewCategory, title: trigger.dataset.header, addOnPage: trigger.dataset.addonpage, slotId: trigger.dataset.slotid, templateContext: { hidden: showNewCategory, }, }); }); } /** * Constructor for the Modal. * * @param {object} root The root jQuery element for the modal */ constructor(root) { super(root); this.category = null; this.returnUrl = null; this.quizCmId = null; this.loadedForm = false; this.slotId = 0; this.savedFilterCondition = null; } configure(modalConfig) { modalConfig.removeOnClose = true; this.setCategory(modalConfig.category); this.setReturnUrl(modalConfig.returnUrl); this.showNewCategory = modalConfig.showNewCategory; this.setSlotId(modalConfig.slotId ?? 0); this.setSavedFilterCondition(modalConfig.savedFilterCondition ?? null); super.configure(modalConfig); } /** * Set the id of the page that the question should be added to * when the user clicks the add to quiz link. * * @method setAddOnPageId * @param {int} id */ setAddOnPageId(id) { super.setAddOnPageId(id); this.getBody().find(SELECTORS.ADD_ON_PAGE_FORM_ELEMENT).val(id); } /** * Set the category for this form. The category is a comma separated * category id and category context id. * * @method setCategory * @param {string} category */ setCategory(category) { this.category = category; } /** * Returns the saved category. * * @method getCategory * @return {string} */ getCategory() { return this.category; } /** * Set the return URL for the form. * * @method setReturnUrl * @param {string} url */ setReturnUrl(url) { this.returnUrl = url; } /** * Returns the return URL for the form. * * @method getReturnUrl * @return {string} */ getReturnUrl() { return this.returnUrl; } /** * Set the ID of the quiz slot, if we are editing an existing random question. * * @param {Number} slotId */ setSlotId(slotId) { this.slotId = slotId; } /** * Get the current slot ID. * * @return {Number} */ getSlotId() { return this.slotId; } /** * Store the current filterCondition JSON string. * * @param {String} filterCondition */ setSavedFilterCondition(filterCondition) { this.savedFilterCondition = filterCondition; } /** * Return the saved filterCondition JSON string. * * @return {String} */ getSavedFilterCondition() { return this.savedFilterCondition; } /** * Moves a given form element inside (a child of) a given tab element. * * Hides the 'legend' (e.g. header) element of the form element because the * tab has the name. * * Moves the submit button into a footer element at the bottom of the form * element for styling purposes. * * @method moveContentIntoTab * @param {jquery} tabContent The form element to move into the tab. * @param {jquey} tabElement The tab element for the form element to move into. */ moveContentIntoTab(tabContent, tabElement) { // Hide the header because the tabs show us which part of the form we're // looking at. tabContent.find(SELECTORS.FORM_HEADER).addClass('hidden'); // Move the element inside a tab. tabContent.wrap(tabElement); } /** * Empty the tab content container and move all tabs from the form into the * tab container element. * * @method moveTabsIntoTabContent * @param {jquery} form The form element. */ moveTabsIntoTabContent(form) { // Empty it to remove the loading icon. const tabContent = this.getBody().find(SELECTORS.TAB_CONTENT).empty(); // Make sure all tabs are inside the tab content element. form.find('[role="tabpanel"]').wrapAll(tabContent); } /** * Make sure all of the tabs have a cancel button in their fotter to sit along * side the submit button. * * @method moveCancelButtonToTabs * @param {jquey} form The form element. */ moveCancelButtonToTabs(form) { const cancelButton = form.find(SELECTORS.CANCEL_BUTTON_ELEMENT).addClass('ms-1'); const tabFooters = form.find('[data-region="footer"]'); // Remove the buttons container element. cancelButton.closest(SELECTORS.BUTTON_CONTAINER).remove(); cancelButton.clone().appendTo(tabFooters); } /** * Load the add random question form in a fragement and perform some transformation * on the HTML to convert it into tabs for rendering in the modal. * * @method loadForm * @return {promise} Resolved with form HTML and JS. */ loadForm() { const addonpage = this.getAddOnPageId(); const returnurl = this.getReturnUrl(); const quizcmid = this.quizCmId; const bankcmid = this.bankCmId; const savedfiltercondition = this.getSavedFilterCondition(); this.setSavedFilterCondition(null); return Fragment.loadFragment( 'mod_quiz', 'add_random_question_form', this.getContextId(), { addonpage: addonpage ?? null, returnurl, quizcmid, bankcmid, slotid: this.getSlotId(), savedfiltercondition, } ) .then((html, js) => { const form = $(html); if (!this.getSlotId()) { const existingCategoryTabContent = form.find(SELECTORS.EXISTING_CATEGORY_TAB); const existingCategoryTab = this.getBody().find(SELECTORS.EXISTING_CATEGORY_CONTAINER); const newCategoryTabContent = form.find(SELECTORS.NEW_CATEGORY_TAB); const newCategoryTab = this.getBody().find(SELECTORS.NEW_CATEGORY_CONTAINER); // Transform the form into tabs for better rendering in the modal. this.moveContentIntoTab(existingCategoryTabContent, existingCategoryTab); this.moveContentIntoTab(newCategoryTabContent, newCategoryTab); this.moveTabsIntoTabContent(form); } Templates.replaceNode(this.getBody().find(SELECTORS.TAB_CONTENT), form, js); return; }) .then(() => { // Make sure the form change checker is disabled otherwise it'll stop the user from navigating away from the // page once the modal is hidden. FormChangeChecker.disableAllChecks(); // Add question to quiz. this.getBody()[0].addEventListener('click', (e) => { const button = e.target.closest(SELECTORS.SUBMIT_BUTTON_ELEMENT); if (!button) { return; } e.preventDefault(); // Intercept the submission to adjust the POST params so that the quiz mod id is set and not the bank module id. document.querySelector('#questionscontainer input[name="cmid"]').setAttribute('name', this.quizCmId); // Add Random questions if the add random button was clicked. const addRandomButton = e.target.closest(SELECTORS.ADD_RANDOM_BUTTON); if (addRandomButton) { const randomcount = document.querySelector(SELECTORS.SELECT_NUMBER_TO_ADD).value; const filtercondition = document.querySelector(SELECTORS.FILTER_CONDITION_ELEMENT).dataset?.filtercondition; this.addQuestions(quizcmid, addonpage, randomcount, filtercondition, '', ''); return; } // Update the filter condition for the slot if the update button was clicked. const updateFilterButton = e.target.closest(SELECTORS.UPDATE_FILTER_BUTTON); if (updateFilterButton) { const filtercondition = document.querySelector(SELECTORS.FILTER_CONDITION_ELEMENT).dataset?.filtercondition; this.updateFilterCondition(quizcmid, this.getSlotId(), filtercondition); return; } // Add new category if the add category button was clicked. const addCategoryButton = e.target.closest(SELECTORS.ADD_NEW_CATEGORY_BUTTON); if (addCategoryButton) { this.addQuestions( quizcmid, addonpage, 1, '', document.querySelector(SELECTORS.NEW_CATEGORY_ELEMENT).value, document.querySelector(SELECTORS.PARENT_CATEGORY_ELEMENT).value ); return; } }); this.getModal().on('click', SELECTORS.SWITCH_TO_OTHER_BANK, () => { this.setSavedFilterCondition( document.querySelector(SELECTORS.FILTER_CONDITION_ELEMENT).dataset?.filtercondition ); this.handleSwitchBankContentReload(SELECTORS.BANK_SEARCH) .then(function(ModalQuizQuestionBank) { $(SELECTORS.BANK_SEARCH)?.on('change', (e) => { const bankCmId = $(e.currentTarget).val(); // Have to recreate the modal as we have already used the body for the switch bank content. if (bankCmId > 0) { ModalAddRandomQuestion.create({ 'contextId': ModalQuizQuestionBank.getContextId(), 'bankCmId': bankCmId, 'category': ModalQuizQuestionBank.getCategory(), 'returnUrl': ModalQuizQuestionBank.getReturnUrl(), 'quizCmId': ModalQuizQuestionBank.quizCmId, 'title': ModalQuizQuestionBank.originalTitle, 'addOnPage': ModalQuizQuestionBank.getAddOnPageId(), 'templateContext': {hidden: ModalQuizQuestionBank.showNewCategory}, 'showNewCategory': ModalQuizQuestionBank.showNewCategory, 'slotId': ModalQuizQuestionBank.getSlotId(), }) .then(ModalQuizQuestionBank.destroy()) .catch(Notification.exception); } }); return ModalQuizQuestionBank; }); }); this.getModal().on('click', SELECTORS.GO_BACK_BUTTON, (e) => { const anchorElement = $(e.currentTarget); // Have to recreate the modal as we have already used the body for the switch bank content. ModalAddRandomQuestion.create({ 'contextId': this.getContextId(), 'bankCmId': anchorElement.attr('value'), 'category': this.getCategory(), 'returnUrl': this.getReturnUrl(), 'quizCmId': this.quizCmId, 'title': this.originalTitle, 'addOnPage': this.getAddOnPageId(), 'templateContext': {hidden: this.showNewCategory}, 'showNewCategory': this.showNewCategory, 'savedFilterCondition': this.getSavedFilterCondition(), 'slotId': this.getSlotId(), }).then(this.destroy()).catch(Notification.exception); }); this.getModal().on('click', SELECTORS.ANCHOR, (e) => { const anchorElement = $(e.currentTarget); // Have to recreate the modal as we have already used the body for the switch bank content. if (anchorElement.closest('a[' + SELECTORS.NEW_BANKMOD_ID + ']').length) { ModalAddRandomQuestion.create({ 'contextId': this.getContextId(), 'bankCmId': anchorElement.attr(SELECTORS.NEW_BANKMOD_ID), 'category': this.getCategory(), 'returnUrl': this.getReturnUrl(), 'quizCmId': this.quizCmId, 'title': this.originalTitle, 'addOnPage': this.getAddOnPageId(), 'templateContext': {hidden: this.showNewCategory}, 'showNewCategory': this.showNewCategory, 'slotId': this.getSlotId(), }).then(this.destroy()).catch(Notification.exception); } }); }) .catch(Notification.exception); } /** * Call web service function to add random questions * * @param {number} quizcmid the course module id of the quiz to add questions to. * @param {number} addonpage the page where random questions will be added to * @param {number} randomcount Number of random questions * @param {string} filtercondition Filter condition * @param {string} newcategory add new category * @param {string} parentcategory parent category of new category */ async addQuestions( quizcmid, addonpage, randomcount, filtercondition, newcategory, parentcategory ) { // We do not need to resolve this Pending because the form submission will result in a page redirect. new Pending('mod-quiz/modal_add_random_questions'); const call = { methodname: 'mod_quiz_add_random_questions', args: { cmid: quizcmid, addonpage, randomcount, filtercondition, newcategory, parentcategory, } }; try { const response = await fetchMany([call])[0]; const form = document.querySelector(SELECTORS.FORM_ELEMENT); const messageInput = form.querySelector(SELECTORS.MESSAGE_INPUT); messageInput.value = response.message; form.submit(); } catch (e) { Notification.exception(e); } } /** * Call web service function to update the filter condition for an existing slot. * * @param {number} quizcmid the course module id of the quiz. * @param {number} slotid The slot the random question is in. * @param {string} filtercondition The new filter condition. */ async updateFilterCondition( quizcmid, slotid, filtercondition, ) { // We do not need to resolve this Pending because the form submission will result in a page redirect. new Pending('mod-quiz/modal_add_random_questions'); const call = { methodname: 'mod_quiz_update_filter_condition', args: { cmid: quizcmid, slotid, filtercondition, } }; try { const response = await fetchMany([call])[0]; const form = document.querySelector(SELECTORS.FORM_ELEMENT); const messageInput = form.querySelector(SELECTORS.MESSAGE_INPUT); messageInput.value = response.message; form.submit(); } catch (e) { Notification.exception(e); } } /** * Override the modal show function to load the form when this modal is first * shown. * * @method show */ show() { super.show(this); if (!this.loadedForm) { this.tabHtml = this.getBody(); this.loadForm(window.location.search); this.loadedForm = true; } } } ModalAddRandomQuestion.registerModalType(); PK9B\x8 amd/src/reopen_attempt_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 . /** * This module has the code to make the Re-open attempt button work, if present. * * That is, it looks for buttons with HTML like * <button type="button" data-action="reopen-attempt" data-attempt-id="227000" data-after-action-url="/mod/quiz/report.php"> * and if that is clicked, it first shows an 'Are you sure' pop-up, and if they are sure, * the attempt is re-opened, and then the page reloads. * * @module mod_quiz/reopen_attempt_ui * @copyright 2023 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ import {exception as displayException} from 'core/notification'; import {call as fetchMany} from 'core/ajax'; import {getString} from 'core/str'; import {saveCancelPromise} from 'core/notification'; /** * Handle a click if it is on one of our buttons. * * @param {MouseEvent} e the click event. */ const reopenButtonClicked = async(e) => { if (!(e.target instanceof HTMLElement) || !e.target.matches('button[data-action="reopen-attempt"]')) { return; } e.preventDefault(); const attemptId = e.target.dataset.attemptId; try { // We fetch the confirmation message from the server now, so the message is based // on the latest state of the attempt, rather than when the containing page loaded. const messages = fetchMany([{ methodname: 'mod_quiz_get_reopen_attempt_confirmation', args: { "attemptid": attemptId, }, }]); await saveCancelPromise( getString('reopenattemptareyousuretitle', 'mod_quiz'), messages[0], getString('reopenattempt', 'mod_quiz'), {triggerElement: e.target}, ); await (fetchMany([{ methodname: 'mod_quiz_reopen_attempt', args: { "attemptid": attemptId, }, }])[0]); window.location = M.cfg.wwwroot + e.target.dataset.afterActionUrl; } catch (error) { if (error.type === 'modal-save-cancel:cancel') { // User clicked Cancel, so do nothing. return; } await displayException(error); } }; export const init = () => { document.addEventListener('click', reopenButtonClicked); }; PK9B\%hA"A"#amd/src/modal_quiz_question_bank.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 question bank modal. * * @module mod_quiz/modal_quiz_question_bank * @copyright 2018 Ryan Wyllie * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ import Modal from './add_question_modal'; import * as Fragment from 'core/fragment'; import * as FormChangeChecker from 'core_form/changechecker'; import * as ModalEvents from 'core/modal_events'; import * as Notification from 'core/notification'; const SELECTORS = { ADD_TO_QUIZ_CONTAINER: 'td.addtoquizaction', ANCHOR: 'a[href]', PREVIEW_CONTAINER: 'td.previewquestionaction', ADD_QUESTIONS_FORM: 'form#questionsubmit', SORTERS: '.sorters', SWITCH_TO_OTHER_BANK: 'button[data-action="switch-question-bank"]', NEW_BANKMOD_ID: 'data-newmodid', BANK_SEARCH: '#searchbanks', GO_BACK_BUTTON: 'button[data-action="go-back"]', ADD_ON_PAGE_FORM_ELEMENT: 'input[name="addonpage"]', CMID_FORM_ELEMENT: 'form#questionsubmit input[name="cmid"]', }; export default class ModalQuizQuestionBank extends Modal { static TYPE = 'mod_quiz-quiz-question-bank'; /** * Create the question bank modal. * * @param {Number} contextId Current module context id. * @param {Number} bankCmId Current question bank course module id. * @param {Number} quizCmId Current quiz course module id. */ static init(contextId, bankCmId, quizCmId) { const selector = '.menu [data-action="questionbank"]'; document.addEventListener('click', (e) => { const trigger = e.target.closest(selector); if (!trigger) { return; } e.preventDefault(); ModalQuizQuestionBank.create({ contextId, quizCmId, bankCmId, title: trigger.dataset.header, addOnPage: trigger.dataset.addonpage, templateContext: { hidden: true, }, large: true, }); }); } /** * Override the parent show function. * * Reload the body contents when the modal is shown. The current * window URL is used to inform the new content that should be * displayed. * * @method show * @return {void} */ show() { this.reloadBodyContent(window.location.search); return super.show(this); } /** * Replaces the current body contents with a new version of the question * bank. * * The contents of the question bank are generated using the provided * query string. * * @method reloadBodyContent * @param {string} querystring URL encoded string. */ reloadBodyContent(querystring) { // Load the question bank fragment to be displayed in the modal and hide the 'go back' button. this.hideFooter(); this.setTitle(this.originalTitle); this.setBody(Fragment.loadFragment( 'mod_quiz', 'quiz_question_bank', this.getContextId(), { querystring, quizcmid: this.quizCmId, bankcmid: this.bankCmId, } )); } /** * Update the URL of the anchor element that the user clicked on to make * sure that the question is added to the correct page. * * @method handleAddToQuizEvent * @param {event} e A JavaScript event * @param {object} anchorElement The anchor element that was triggered */ handleAddToQuizEvent(e, anchorElement) { // If the user clicks the plus icon to add the question to the page // directly then we need to intercept the click in order to adjust the // href and include the correct add on page id and cmid before the page is // redirected. const href = new URL(anchorElement.getAttribute('href')); href.searchParams.set('addonpage', this.getAddOnPageId()); href.searchParams.set('cmid', this.quizCmId); anchorElement.setAttribute('href', href); } /** * Set up all of the event handling for the modal. * * @method registerEventListeners */ registerEventListeners() { // Apply parent event listeners. super.registerEventListeners(this); this.getModal().on('submit', SELECTORS.ADD_QUESTIONS_FORM, (e) => { // If the user clicks on the "Add selected questions to the quiz" button to add some questions to the page // then we need to intercept the submit in order to include the correct "add on page id" // and the quizmod id before the form is submitted. const formElement = e.currentTarget; document.querySelector(SELECTORS.ADD_ON_PAGE_FORM_ELEMENT).setAttribute('value', this.getAddOnPageId()); // We also need to set the form cmid & action as the quiz modid as this could be coming from a module that isn't a quiz. document.querySelector(SELECTORS.CMID_FORM_ELEMENT).setAttribute('value', this.quizCmId); const actionUrl = new URL(formElement.getAttribute('action')); actionUrl.searchParams.set('cmid', this.quizCmId); formElement.setAttribute('action', actionUrl.toString()); }); this.getModal().on('click', SELECTORS.SWITCH_TO_OTHER_BANK, () => { this.handleSwitchBankContentReload(SELECTORS.BANK_SEARCH) .then(function(ModalQuizQuestionBank) { document.querySelector(SELECTORS.BANK_SEARCH)?.addEventListener('change', (e) => { const bankCmId = e.currentTarget.value; if (bankCmId > 0) { ModalQuizQuestionBank.bankCmId = bankCmId; ModalQuizQuestionBank.reloadBodyContent(window.location.search); } }); document.querySelector(SELECTORS.GO_BACK_BUTTON).addEventListener('click', (e) => { ModalQuizQuestionBank.bankCmId = e.currentTarget.value; ModalQuizQuestionBank.reloadBodyContent(window.location.search); }); } ) .catch(Notification.exception); }); this.getModal().on('click', SELECTORS.ANCHOR, (e) => { const anchorElement = e.currentTarget; // If the anchor element was the add to quiz link. if (anchorElement.closest(SELECTORS.ADD_TO_QUIZ_CONTAINER)) { this.handleAddToQuizEvent(e, anchorElement); return; } // If the anchor element was a preview question link. if (anchorElement.closest(SELECTORS.PREVIEW_CONTAINER)) { return; } // Sorting links have their own handler. if (anchorElement.closest(SELECTORS.SORTERS)) { return; } if (anchorElement.closest('a[' + SELECTORS.NEW_BANKMOD_ID + ']')) { this.bankCmId = anchorElement.getAttribute(SELECTORS.NEW_BANKMOD_ID); // We need to clear the filter as we are about to reload the content. const url = new URL(location.href); url.searchParams.delete('filter'); history.pushState({}, '', url); } // Anything else means reload the pop-up contents. e.preventDefault(); this.reloadBodyContent(anchorElement.search); }); // Disable the form change checker when the body is rendered. this.getRoot().on(ModalEvents.bodyRendered, () => { // Make sure the form change checker is disabled otherwise it'll stop the user from navigating away from the // page once the modal is hidden. FormChangeChecker.disableAllChecks(); }); } } ModalQuizQuestionBank.registerModalType(); PK9B\s>"z z -amd/build/random_question_form_preview.min.jsnu[/** * JavaScript for the random_question_form_preview of the * add_random_form class. * * @module mod_quiz/random_question_form_preview * @copyright 2018 Ryan Wyllie * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ define("mod_quiz/random_question_form_preview",["jquery","core/ajax","core/str","core/notification","core/templates","core/paged_content_factory"],(function($,Ajax,Str,Notification,Templates,PagedContentFactory){var TEMPLATE_NAME="mod_quiz/random_question_form_preview_question_list",SELECTORS_LOADING_ICON_CONTAINER='[data-region="overlay-icon-container"]',SELECTORS_QUESTION_COUNT_CONTAINER='[data-region="question-count-container"]',SELECTORS_QUESTION_LIST_CONTAINER='[data-region="question-list-container"]',showLoadingIcon=function(root){root.find(SELECTORS_LOADING_ICON_CONTAINER).removeClass("hidden")},hideLoadingIcon=function(root){root.find(SELECTORS_LOADING_ICON_CONTAINER).addClass("hidden")},requestQuestions=function(categoryId,includeSubcategories,tagIds,contextId,limit,offset){var request={methodname:"core_question_get_random_question_summaries",args:{categoryid:categoryId,includesubcategories:includeSubcategories,tagids:tagIds,contextid:contextId,limit:limit,offset:offset}};return Ajax.call([request])[0]};return{reload:function(root,categoryId,includeSubcategories,tagIds,contextId){return showLoadingIcon(root),requestQuestions(categoryId,includeSubcategories,tagIds,contextId,5,0).then((function(response){var totalCount=response.totalcount;return function(root,questionCount){Str.get_string("questionsmatchingfilter","mod_quiz",questionCount).then((function(string){root.find(SELECTORS_QUESTION_COUNT_CONTAINER).html(string)})).fail(Notification.exception)}(root,totalCount),response})).then((function(response){var totalQuestionCount=response.totalcount,questions=response.questions;return questions.length?function(categoryId,includeSubcategories,tagIds,contextId,totalQuestionCount,firstPageQuestions){return PagedContentFactory.createFromAjax(totalQuestionCount,5,(function(pagesData){return pagesData.map((function(pageData){var limit=pageData.limit,offset=pageData.offset;return 0==offset?Templates.render(TEMPLATE_NAME,{questions:firstPageQuestions}):requestQuestions(categoryId,includeSubcategories,tagIds,contextId,limit,offset).then((function(response){var questions=response.questions;return Templates.render(TEMPLATE_NAME,{questions:questions})})).fail(Notification.exception)}))}))}(categoryId,includeSubcategories,tagIds,contextId,totalQuestionCount,questions):$.Deferred().resolve("","")})).then((function(html,js){var container=root.find(SELECTORS_QUESTION_LIST_CONTAINER);Templates.replaceNodeContents(container,html,js)})).always((function(){hideLoadingIcon(root)})).fail(Notification.exception)},showLoadingIcon:showLoadingIcon,hideLoadingIcon:hideLoadingIcon}})); //# sourceMappingURL=random_question_form_preview.min.js.mapPK9B\{,amd/build/submission_confirmation.min.js.mapnu[{"version":3,"file":"submission_confirmation.min.js","sources":["../src/submission_confirmation.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 submission confirmation for quiz.\n *\n * @module mod_quiz/submission_confirmation\n * @copyright 2022 Huong Nguyen \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n * @since 4.1\n */\n\nimport {saveCancelPromise} from 'core/notification';\nimport Prefetch from 'core/prefetch';\nimport Templates from 'core/templates';\nimport {getString} from 'core/str';\n\nconst SELECTOR = {\n attemptSubmitButton: '.path-mod-quiz .btn-finishattempt button',\n attemptSubmitForm: 'form#frm-finishattempt',\n};\n\nconst TEMPLATES = {\n submissionConfirmation: 'mod_quiz/submission_confirmation',\n};\n\n/**\n * Register events for attempt submit button.\n * @param {int} unAnsweredQuestions Total number of un-answered questions\n */\nconst registerEventListeners = (unAnsweredQuestions) => {\n const submitAction = document.querySelector(SELECTOR.attemptSubmitButton);\n if (submitAction) {\n submitAction.addEventListener('click', async(e) => {\n e.preventDefault();\n try {\n await saveCancelPromise(\n getString('submission_confirmation', 'quiz'),\n Templates.render(TEMPLATES.submissionConfirmation, {\n hasunanswered: unAnsweredQuestions > 0,\n totalunanswered: unAnsweredQuestions\n }),\n getString('submitallandfinish', 'quiz')\n );\n\n // Save pressed.\n submitAction.closest(SELECTOR.attemptSubmitForm).submit();\n } catch {\n // Cancel pressed.\n return;\n }\n });\n }\n};\n\n/**\n * Initialises.\n * @param {int} unAnsweredQuestions Total number of unanswered questions\n */\nexport const init = (unAnsweredQuestions) => {\n Prefetch.prefetchStrings('core', ['submit']);\n Prefetch.prefetchStrings('core_admin', ['confirmation']);\n Prefetch.prefetchStrings('quiz', ['submitallandfinish', 'submission_confirmation']);\n Prefetch.prefetchTemplate(TEMPLATES.submissionConfirmation);\n registerEventListeners(unAnsweredQuestions);\n};\n"],"names":["SELECTOR","TEMPLATES","unAnsweredQuestions","prefetchStrings","prefetchTemplate","submitAction","document","querySelector","addEventListener","async","e","preventDefault","Templates","render","hasunanswered","totalunanswered","closest","submit","registerEventListeners"],"mappings":";;;;;;;;kLA6BMA,6BACmB,2CADnBA,2BAEiB,yBAGjBC,iCACsB,iDAoCPC,wCACRC,gBAAgB,OAAQ,CAAC,6BACzBA,gBAAgB,aAAc,CAAC,mCAC/BA,gBAAgB,OAAQ,CAAC,qBAAsB,8CAC/CC,iBAAiBH,kCAjCEC,CAAAA,4BACtBG,aAAeC,SAASC,cAAcP,8BACxCK,cACAA,aAAaG,iBAAiB,SAASC,MAAAA,IACnCC,EAAEC,2BAEQ,oCACF,kBAAU,0BAA2B,QACrCC,mBAAUC,OAAOZ,iCAAkC,CAC/Ca,cAAeZ,oBAAsB,EACrCa,gBAAiBb,uBAErB,kBAAU,qBAAsB,SAIpCG,aAAaW,QAAQhB,4BAA4BiB,SACnD,kBAiBVC,CAAuBhB"}PK9B\6U U "amd/build/question_slot.min.js.mapnu[{"version":3,"file":"question_slot.min.js","sources":["../src/question_slot.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 * Render the question slot template for each question in the quiz edit view.\n *\n * @module mod_quiz/question_slot\n * @copyright 2021 Catalyst IT Australia Pty Ltd\n * @author Guillermo Gomez Arias \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\n/**\n * Set the question version for the slot.\n *\n * @param {Number} slotId\n * @param {Number} newVersion\n * @return {Array} The modified question version\n */\nconst setQuestionVersion = (slotId, newVersion) => fetchMany([{\n methodname: 'mod_quiz_set_question_version',\n args: {\n slotid: slotId,\n newversion: newVersion,\n }\n}])[0];\n\n/**\n * Replace the container with a new version.\n */\nconst registerEventListeners = () => {\n document.addEventListener('change', e => {\n if (!e.target.matches('[data-action=\"mod_quiz-select_slot\"][data-slot-id]')) {\n return;\n }\n\n const slotId = e.target.dataset.slotId;\n const newVersion = parseInt(e.target.value);\n\n setQuestionVersion(slotId, newVersion === 0 ? null : newVersion)\n .then(() => {\n location.reload();\n return;\n })\n .catch(Notification.exception);\n });\n};\n\n/**\n * Entrypoint of the js.\n */\nexport const init = () => {\n registerEventListeners();\n};\n"],"names":["registerEventListeners","document","addEventListener","e","target","matches","slotId","dataset","newVersion","parseInt","value","methodname","args","slotid","newversion","setQuestionVersion","then","location","reload","catch","Notification","exception"],"mappings":";;;;;;;;4JA6CMA,uBAAyB,KAC3BC,SAASC,iBAAiB,UAAUC,QAC3BA,EAAEC,OAAOC,QAAQ,mEAIhBC,OAASH,EAAEC,OAAOG,QAAQD,OAC1BE,WAAaC,SAASN,EAAEC,OAAOM,OAlBlB,EAACJ,OAAQE,cAAe,cAAU,CAAC,CAC1DG,WAAY,gCACZC,KAAM,CACFC,OAAQP,OACRQ,WAAYN,eAEhB,GAcIO,CAAmBT,OAAuB,IAAfE,WAAmB,KAAOA,YAChDQ,MAAK,KACFC,SAASC,YAGZC,MAAMC,sBAAaC,6BAOZ,KAChBrB"}PK9B\dv--*amd/build/modal_add_random_question.min.jsnu[define("mod_quiz/modal_add_random_question",["exports","jquery","./add_question_modal","core/notification","core/fragment","core/templates","core_form/changechecker","core/ajax","core/pending"],(function(_exports,_jquery,_add_question_modal,Notification,Fragment,Templates,FormChangeChecker,_ajax,_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 _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}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),_add_question_modal=_interopRequireDefault(_add_question_modal),Notification=_interopRequireWildcard(Notification),Fragment=_interopRequireWildcard(Fragment),Templates=_interopRequireWildcard(Templates),FormChangeChecker=_interopRequireWildcard(FormChangeChecker),_pending=_interopRequireDefault(_pending);const SELECTORS={ANCHOR:"a[href]",EXISTING_CATEGORY_CONTAINER:'[data-region="existing-category-container"]',EXISTING_CATEGORY_TAB:"#id_existingcategoryheader",NEW_CATEGORY_CONTAINER:'[data-region="new-category-container"]',NEW_CATEGORY_TAB:"#id_newcategoryheader",TAB_CONTENT:'[data-region="tab-content"]',ADD_ON_PAGE_FORM_ELEMENT:'[name="addonpage"]',ADD_RANDOM_BUTTON:'input[type="submit"][name="addrandom"]',ADD_NEW_CATEGORY_BUTTON:'input[type="submit"][name="newcategory"]',SUBMIT_BUTTON_ELEMENT:'input[type="submit"][name="addrandom"], input[type="submit"][name="newcategory"], input[type="submit"][name="update"]',FORM_HEADER:"legend",SELECT_NUMBER_TO_ADD:"#menurandomcount",NEW_CATEGORY_ELEMENT:"#categoryname",PARENT_CATEGORY_ELEMENT:"#parentcategory",FILTER_CONDITION_ELEMENT:"[data-filtercondition]",FORM_ELEMENT:"#add_random_question_form",MESSAGE_INPUT:'[name="message"]',SWITCH_TO_OTHER_BANK:'button[data-action="switch-question-bank"]',NEW_BANKMOD_ID:"data-newmodid",BANK_SEARCH:"#searchbanks",GO_BACK_BUTTON:'button[data-action="go-back"]',UPDATE_FILTER_BUTTON:'input[type="submit"][name="update"]'};class ModalAddRandomQuestion extends _add_question_modal.default{static init(contextId,bankCmId,category,returnUrl,quizCmId){let showNewCategory=!(arguments.length>5&&void 0!==arguments[5])||arguments[5];document.addEventListener("click",(e=>{const trigger=e.target.closest('.menu [data-action="addarandomquestion"], [data-action="editrandomquestion"]');trigger&&(e.preventDefault(),trigger.dataset.slotid&&(showNewCategory=!1),ModalAddRandomQuestion.create({contextId:contextId,bankCmId:bankCmId,category:category,returnUrl:returnUrl,quizCmId:quizCmId,showNewCategory:showNewCategory,title:trigger.dataset.header,addOnPage:trigger.dataset.addonpage,slotId:trigger.dataset.slotid,templateContext:{hidden:showNewCategory}}))}))}constructor(root){super(root),this.category=null,this.returnUrl=null,this.quizCmId=null,this.loadedForm=!1,this.slotId=0,this.savedFilterCondition=null}configure(modalConfig){var _modalConfig$slotId,_modalConfig$savedFil;modalConfig.removeOnClose=!0,this.setCategory(modalConfig.category),this.setReturnUrl(modalConfig.returnUrl),this.showNewCategory=modalConfig.showNewCategory,this.setSlotId(null!==(_modalConfig$slotId=modalConfig.slotId)&&void 0!==_modalConfig$slotId?_modalConfig$slotId:0),this.setSavedFilterCondition(null!==(_modalConfig$savedFil=modalConfig.savedFilterCondition)&&void 0!==_modalConfig$savedFil?_modalConfig$savedFil:null),super.configure(modalConfig)}setAddOnPageId(id){super.setAddOnPageId(id),this.getBody().find(SELECTORS.ADD_ON_PAGE_FORM_ELEMENT).val(id)}setCategory(category){this.category=category}getCategory(){return this.category}setReturnUrl(url){this.returnUrl=url}getReturnUrl(){return this.returnUrl}setSlotId(slotId){this.slotId=slotId}getSlotId(){return this.slotId}setSavedFilterCondition(filterCondition){this.savedFilterCondition=filterCondition}getSavedFilterCondition(){return this.savedFilterCondition}moveContentIntoTab(tabContent,tabElement){tabContent.find(SELECTORS.FORM_HEADER).addClass("hidden"),tabContent.wrap(tabElement)}moveTabsIntoTabContent(form){const tabContent=this.getBody().find(SELECTORS.TAB_CONTENT).empty();form.find('[role="tabpanel"]').wrapAll(tabContent)}moveCancelButtonToTabs(form){const cancelButton=form.find(SELECTORS.CANCEL_BUTTON_ELEMENT).addClass("ms-1"),tabFooters=form.find('[data-region="footer"]');cancelButton.closest(SELECTORS.BUTTON_CONTAINER).remove(),cancelButton.clone().appendTo(tabFooters)}loadForm(){const addonpage=this.getAddOnPageId(),returnurl=this.getReturnUrl(),quizcmid=this.quizCmId,bankcmid=this.bankCmId,savedfiltercondition=this.getSavedFilterCondition();return this.setSavedFilterCondition(null),Fragment.loadFragment("mod_quiz","add_random_question_form",this.getContextId(),{addonpage:null!=addonpage?addonpage:null,returnurl:returnurl,quizcmid:quizcmid,bankcmid:bankcmid,slotid:this.getSlotId(),savedfiltercondition:savedfiltercondition}).then(((html,js)=>{const form=(0,_jquery.default)(html);if(!this.getSlotId()){const existingCategoryTabContent=form.find(SELECTORS.EXISTING_CATEGORY_TAB),existingCategoryTab=this.getBody().find(SELECTORS.EXISTING_CATEGORY_CONTAINER),newCategoryTabContent=form.find(SELECTORS.NEW_CATEGORY_TAB),newCategoryTab=this.getBody().find(SELECTORS.NEW_CATEGORY_CONTAINER);this.moveContentIntoTab(existingCategoryTabContent,existingCategoryTab),this.moveContentIntoTab(newCategoryTabContent,newCategoryTab),this.moveTabsIntoTabContent(form)}Templates.replaceNode(this.getBody().find(SELECTORS.TAB_CONTENT),form,js)})).then((()=>{FormChangeChecker.disableAllChecks(),this.getBody()[0].addEventListener("click",(e=>{if(!e.target.closest(SELECTORS.SUBMIT_BUTTON_ELEMENT))return;e.preventDefault(),document.querySelector('#questionscontainer input[name="cmid"]').setAttribute("name",this.quizCmId);if(e.target.closest(SELECTORS.ADD_RANDOM_BUTTON)){var _document$querySelect;const randomcount=document.querySelector(SELECTORS.SELECT_NUMBER_TO_ADD).value,filtercondition=null===(_document$querySelect=document.querySelector(SELECTORS.FILTER_CONDITION_ELEMENT).dataset)||void 0===_document$querySelect?void 0:_document$querySelect.filtercondition;return void this.addQuestions(quizcmid,addonpage,randomcount,filtercondition,"","")}if(e.target.closest(SELECTORS.UPDATE_FILTER_BUTTON)){var _document$querySelect2;const filtercondition=null===(_document$querySelect2=document.querySelector(SELECTORS.FILTER_CONDITION_ELEMENT).dataset)||void 0===_document$querySelect2?void 0:_document$querySelect2.filtercondition;return void this.updateFilterCondition(quizcmid,this.getSlotId(),filtercondition)}e.target.closest(SELECTORS.ADD_NEW_CATEGORY_BUTTON)&&this.addQuestions(quizcmid,addonpage,1,"",document.querySelector(SELECTORS.NEW_CATEGORY_ELEMENT).value,document.querySelector(SELECTORS.PARENT_CATEGORY_ELEMENT).value)})),this.getModal().on("click",SELECTORS.SWITCH_TO_OTHER_BANK,(()=>{var _document$querySelect3;this.setSavedFilterCondition(null===(_document$querySelect3=document.querySelector(SELECTORS.FILTER_CONDITION_ELEMENT).dataset)||void 0===_document$querySelect3?void 0:_document$querySelect3.filtercondition),this.handleSwitchBankContentReload(SELECTORS.BANK_SEARCH).then((function(ModalQuizQuestionBank){var _$;return null===(_$=(0,_jquery.default)(SELECTORS.BANK_SEARCH))||void 0===_$||_$.on("change",(e=>{const bankCmId=(0,_jquery.default)(e.currentTarget).val();bankCmId>0&&ModalAddRandomQuestion.create({contextId:ModalQuizQuestionBank.getContextId(),bankCmId:bankCmId,category:ModalQuizQuestionBank.getCategory(),returnUrl:ModalQuizQuestionBank.getReturnUrl(),quizCmId:ModalQuizQuestionBank.quizCmId,title:ModalQuizQuestionBank.originalTitle,addOnPage:ModalQuizQuestionBank.getAddOnPageId(),templateContext:{hidden:ModalQuizQuestionBank.showNewCategory},showNewCategory:ModalQuizQuestionBank.showNewCategory,slotId:ModalQuizQuestionBank.getSlotId()}).then(ModalQuizQuestionBank.destroy()).catch(Notification.exception)})),ModalQuizQuestionBank}))})),this.getModal().on("click",SELECTORS.GO_BACK_BUTTON,(e=>{const anchorElement=(0,_jquery.default)(e.currentTarget);ModalAddRandomQuestion.create({contextId:this.getContextId(),bankCmId:anchorElement.attr("value"),category:this.getCategory(),returnUrl:this.getReturnUrl(),quizCmId:this.quizCmId,title:this.originalTitle,addOnPage:this.getAddOnPageId(),templateContext:{hidden:this.showNewCategory},showNewCategory:this.showNewCategory,savedFilterCondition:this.getSavedFilterCondition(),slotId:this.getSlotId()}).then(this.destroy()).catch(Notification.exception)})),this.getModal().on("click",SELECTORS.ANCHOR,(e=>{const anchorElement=(0,_jquery.default)(e.currentTarget);anchorElement.closest("a["+SELECTORS.NEW_BANKMOD_ID+"]").length&&ModalAddRandomQuestion.create({contextId:this.getContextId(),bankCmId:anchorElement.attr(SELECTORS.NEW_BANKMOD_ID),category:this.getCategory(),returnUrl:this.getReturnUrl(),quizCmId:this.quizCmId,title:this.originalTitle,addOnPage:this.getAddOnPageId(),templateContext:{hidden:this.showNewCategory},showNewCategory:this.showNewCategory,slotId:this.getSlotId()}).then(this.destroy()).catch(Notification.exception)}))})).catch(Notification.exception)}async addQuestions(quizcmid,addonpage,randomcount,filtercondition,newcategory,parentcategory){new _pending.default("mod-quiz/modal_add_random_questions");const call={methodname:"mod_quiz_add_random_questions",args:{cmid:quizcmid,addonpage:addonpage,randomcount:randomcount,filtercondition:filtercondition,newcategory:newcategory,parentcategory:parentcategory}};try{const response=await(0,_ajax.call)([call])[0],form=document.querySelector(SELECTORS.FORM_ELEMENT);form.querySelector(SELECTORS.MESSAGE_INPUT).value=response.message,form.submit()}catch(e){Notification.exception(e)}}async updateFilterCondition(quizcmid,slotid,filtercondition){new _pending.default("mod-quiz/modal_add_random_questions");const call={methodname:"mod_quiz_update_filter_condition",args:{cmid:quizcmid,slotid:slotid,filtercondition:filtercondition}};try{const response=await(0,_ajax.call)([call])[0],form=document.querySelector(SELECTORS.FORM_ELEMENT);form.querySelector(SELECTORS.MESSAGE_INPUT).value=response.message,form.submit()}catch(e){Notification.exception(e)}}show(){super.show(this),this.loadedForm||(this.tabHtml=this.getBody(),this.loadForm(window.location.search),this.loadedForm=!0)}}return _exports.default=ModalAddRandomQuestion,_defineProperty(ModalAddRandomQuestion,"TYPE","mod_quiz-quiz-add-random-question"),_defineProperty(ModalAddRandomQuestion,"TEMPLATE","mod_quiz/modal_add_random_question"),ModalAddRandomQuestion.registerModalType(),_exports.default})); //# sourceMappingURL=modal_add_random_question.min.js.mapPK9B\XU%%$amd/build/add_random_form.min.js.mapnu[{"version":3,"file":"add_random_form.min.js","sources":["../src/add_random_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 * JavaScript for the add_random_form class.\n *\n * @module mod_quiz/add_random_form\n * @copyright 2018 Ryan Wyllie \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\ndefine(\n [\n 'jquery',\n 'mod_quiz/random_question_form_preview'\n ],\n function(\n $,\n RandomQuestionFormPreview\n ) {\n\n // Wait 2 seconds before reloading the question set just in case\n // the user is still changing the criteria.\n var RELOAD_DELAY = 2000;\n var SELECTORS = {\n PREVIEW_CONTAINER: '[data-region=\"random-question-preview-container\"]',\n CATEGORY_FORM_ELEMENT: '[name=\"category\"]',\n SUBCATEGORY_FORM_ELEMENT: '[name=\"includesubcategories\"]',\n TAG_IDS_FORM_ELEMENT: '[name=\"fromtags[]\"]'\n };\n\n /**\n * Get the selected category value from the form.\n *\n * @param {jquery} form The form element.\n * @return {string} The category value.\n */\n var getCategorySelectValue = function(form) {\n return form.find(SELECTORS.CATEGORY_FORM_ELEMENT).val();\n };\n\n /**\n * Get the category id from the form.\n *\n * @param {jquery} form The form element.\n * @return {string} The category id.\n */\n var getCategoryId = function(form) {\n // The value string is the category id and category context id joined\n // by a comma.\n var valueString = getCategorySelectValue(form);\n // Split the two ids.\n var values = valueString.split(',');\n // Return just the category id.\n return values[0];\n };\n\n /**\n * Check if a top level category is selected in the form.\n *\n * @param {jquery} form The form element.\n * @param {string[]} topCategories List of top category values (matching the select box values)\n * @return {bool}\n */\n var isTopLevelCategorySelected = function(form, topCategories) {\n var selectedValue = getCategorySelectValue(form);\n return (topCategories.indexOf(selectedValue) > -1);\n };\n\n /**\n * Check if the form indicates we should include include subcategories in\n * the filter.\n *\n * @param {jquery} form The form element.\n * @param {string[]} topCategories List of top category values (matching the select box values)\n * @return {bool}\n */\n var shouldIncludeSubcategories = function(form, topCategories) {\n if (isTopLevelCategorySelected(form, topCategories)) {\n return true;\n } else {\n return form.find(SELECTORS.SUBCATEGORY_FORM_ELEMENT).is(':checked');\n }\n };\n\n /**\n * Get the tag ids for the selected tags in the form.\n *\n * @param {jquery} form The form element.\n * @return {string[]} The tag ids.\n */\n var getTagIds = function(form) {\n var values = form.find(SELECTORS.TAG_IDS_FORM_ELEMENT).val();\n return values.map(function(value) {\n // The tag element value is the tag id and tag name joined\n // by a comma. So we need to split them to get the tag id.\n var parts = value.split(',');\n return parts[0];\n });\n };\n\n /**\n * Reload the preview section with a new set of filters.\n *\n * @param {jquery} form The form element.\n * @param {int} contextId The current context id.\n * @param {string[]} topCategories List of top category values (matching the select box values)\n */\n var reloadQuestionPreview = function(form, contextId, topCategories) {\n var previewContainer = form.find(SELECTORS.PREVIEW_CONTAINER);\n RandomQuestionFormPreview.reload(\n previewContainer,\n getCategoryId(form),\n shouldIncludeSubcategories(form, topCategories),\n getTagIds(form),\n contextId\n );\n };\n\n /**\n * Is this an element we're interested in listening to changes on.\n *\n * @param {jquery} element The element to check.\n * @return {bool}\n */\n var isInterestingElement = function(element) {\n if (element.closest(SELECTORS.CATEGORY_FORM_ELEMENT).length > 0) {\n return true;\n }\n\n if (element.closest(SELECTORS.SUBCATEGORY_FORM_ELEMENT).length > 0) {\n return true;\n }\n\n if (element.closest(SELECTORS.TAG_IDS_FORM_ELEMENT).length > 0) {\n return true;\n }\n\n return false;\n };\n\n /**\n * Listen for changes to any of the interesting elements and reload the form\n * preview with the new filter values if they are changed.\n *\n * The reload is delayed for a small amount of time (see RELOAD_DELAY) in case\n * the user is actively editing the form. This allows us to avoid having to\n * send multiple requests to the server on each change.\n *\n * Instead we can just send a single request when the user appears to have\n * finished editing the form.\n *\n * @param {jquery} form The form element.\n * @param {int} contextId The current context id.\n * @param {string[]} topCategories List of top category values (matching the select box values)\n */\n var addEventListeners = function(form, contextId, topCategories) {\n var reloadTimerId = null;\n const tagsFilter = form.find(SELECTORS.TAG_IDS_FORM_ELEMENT);\n\n form.add(tagsFilter).on('change', function(e) {\n // Only reload the preview when elements that will change the result\n // are modified.\n if (!isInterestingElement($(e.target))) {\n return;\n }\n\n // Show the loading icon to let the user know that the preview\n // will be updated after their actions.\n RandomQuestionFormPreview.showLoadingIcon(form);\n\n if (reloadTimerId) {\n // Reset the timer each time the form is modified.\n clearTimeout(reloadTimerId);\n }\n\n // Don't immediately reload the question preview section just\n // in case the user is still modifying the form. We don't want to\n // spam reload requests.\n reloadTimerId = setTimeout(function() {\n reloadQuestionPreview(form, contextId, topCategories);\n }, RELOAD_DELAY);\n });\n };\n\n /**\n * Trigger the first load of the preview section and then listen for modifications\n * to the form to reload the preview with new filter values.\n *\n * @param {jquery} formId The form element id.\n * @param {int} contextId The current context id.\n * @param {string[]} topCategories List of top category values (matching the select box values)\n * @param {bool} isTagsEnabled Whether tags feature is enabled or not.\n */\n var init = function(formId, contextId, topCategories, isTagsEnabled) {\n if (isTagsEnabled == true) {\n var form = $('#' + formId);\n reloadQuestionPreview(form, contextId, topCategories, isTagsEnabled);\n addEventListeners(form, contextId, topCategories, isTagsEnabled);\n }\n };\n\n return {\n init: init\n };\n});\n"],"names":["define","$","RandomQuestionFormPreview","SELECTORS","getCategorySelectValue","form","find","val","shouldIncludeSubcategories","topCategories","selectedValue","indexOf","isTopLevelCategorySelected","is","reloadQuestionPreview","contextId","previewContainer","reload","split","getCategoryId","map","value","getTagIds","addEventListeners","reloadTimerId","tagsFilter","add","on","e","element","target","closest","length","showLoadingIcon","clearTimeout","setTimeout","init","formId","isTagsEnabled"],"mappings":";;;;;;;AAsBAA,kCACI,CACI,SACA,0CAEJ,SACIC,EACAC,+BAMAC,4BACmB,oDADnBA,gCAEuB,oBAFvBA,mCAG0B,gCAH1BA,+BAIsB,sBAStBC,uBAAyB,SAASC,aAC3BA,KAAKC,KAAKH,iCAAiCI,OAuClDC,2BAA6B,SAASH,KAAMI,uBAbf,SAASJ,KAAMI,mBACxCC,cAAgBN,uBAAuBC,aACnCI,cAAcE,QAAQD,gBAAkB,EAY5CE,CAA2BP,KAAMI,gBAG1BJ,KAAKC,KAAKH,oCAAoCU,GAAG,aA2B5DC,sBAAwB,SAAST,KAAMU,UAAWN,mBAC9CO,iBAAmBX,KAAKC,KAAKH,6BACjCD,0BAA0Be,OACtBD,iBAhEY,SAASX,aAGPD,uBAAuBC,MAEhBa,MAAM,KAEjB,GA0DVC,CAAcd,MACdG,2BAA2BH,KAAMI,eAtBzB,SAASJ,aACRA,KAAKC,KAAKH,gCAAgCI,MACzCa,KAAI,SAASC,cAGXA,MAAMH,MAAM,KACX,MAiBbI,CAAUjB,MACVU,YAyCJQ,kBAAoB,SAASlB,KAAMU,UAAWN,mBAC1Ce,cAAgB,WACdC,WAAapB,KAAKC,KAAKH,gCAE7BE,KAAKqB,IAAID,YAAYE,GAAG,UAAU,SAASC,GAnCpB,IAASC,UAAAA,QAsCF5B,EAAE2B,EAAEE,SArCtBC,QAAQ5B,iCAAiC6B,OAAS,GAI1DH,QAAQE,QAAQ5B,oCAAoC6B,OAAS,GAI7DH,QAAQE,QAAQ5B,gCAAgC6B,OAAS,KAmCzD9B,0BAA0B+B,gBAAgB5B,MAEtCmB,eAEAU,aAAaV,eAMjBA,cAAgBW,YAAW,WACvBrB,sBAAsBT,KAAMU,UAAWN,iBA7JhC,gBAmLZ,CACH2B,KATO,SAASC,OAAQtB,UAAWN,cAAe6B,kBAC5B,GAAjBA,cAAuB,KACnBjC,KAAOJ,EAAE,IAAMoC,QACnBvB,sBAAsBT,KAAMU,UAAWN,eACvCc,kBAAkBlB,KAAMU,UAAWN"}PK9B\G>nn.amd/build/modal_add_random_question.min.js.mapnu[{"version":3,"file":"modal_add_random_question.min.js","sources":["../src/modal_add_random_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 * Contain the logic for the add random question modal.\n *\n * @module mod_quiz/modal_add_random_question\n * @copyright 2018 Ryan Wyllie \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport $ from 'jquery';\nimport Modal from './add_question_modal';\nimport * as Notification from 'core/notification';\nimport * as Fragment from 'core/fragment';\nimport * as Templates from 'core/templates';\nimport * as FormChangeChecker from 'core_form/changechecker';\nimport {call as fetchMany} from 'core/ajax';\nimport Pending from 'core/pending';\n\nconst SELECTORS = {\n ANCHOR: 'a[href]',\n EXISTING_CATEGORY_CONTAINER: '[data-region=\"existing-category-container\"]',\n EXISTING_CATEGORY_TAB: '#id_existingcategoryheader',\n NEW_CATEGORY_CONTAINER: '[data-region=\"new-category-container\"]',\n NEW_CATEGORY_TAB: '#id_newcategoryheader',\n TAB_CONTENT: '[data-region=\"tab-content\"]',\n ADD_ON_PAGE_FORM_ELEMENT: '[name=\"addonpage\"]',\n ADD_RANDOM_BUTTON: 'input[type=\"submit\"][name=\"addrandom\"]',\n ADD_NEW_CATEGORY_BUTTON: 'input[type=\"submit\"][name=\"newcategory\"]',\n SUBMIT_BUTTON_ELEMENT: 'input[type=\"submit\"][name=\"addrandom\"], '\n + 'input[type=\"submit\"][name=\"newcategory\"], '\n + 'input[type=\"submit\"][name=\"update\"]',\n FORM_HEADER: 'legend',\n SELECT_NUMBER_TO_ADD: '#menurandomcount',\n NEW_CATEGORY_ELEMENT: '#categoryname',\n PARENT_CATEGORY_ELEMENT: '#parentcategory',\n FILTER_CONDITION_ELEMENT: '[data-filtercondition]',\n FORM_ELEMENT: '#add_random_question_form',\n MESSAGE_INPUT: '[name=\"message\"]',\n SWITCH_TO_OTHER_BANK: 'button[data-action=\"switch-question-bank\"]',\n NEW_BANKMOD_ID: 'data-newmodid',\n BANK_SEARCH: '#searchbanks',\n GO_BACK_BUTTON: 'button[data-action=\"go-back\"]',\n UPDATE_FILTER_BUTTON: 'input[type=\"submit\"][name=\"update\"]',\n};\n\nexport default class ModalAddRandomQuestion extends Modal {\n static TYPE = 'mod_quiz-quiz-add-random-question';\n static TEMPLATE = 'mod_quiz/modal_add_random_question';\n\n /**\n * Create the add random question modal.\n *\n * @param {Number} contextId Current context id.\n * @param {Number} bankCmId Current question bank course module id.\n * @param {string} category Category id and category context id comma separated.\n * @param {string} returnUrl URL to return to after form submission.\n * @param {Number} quizCmId Current quiz course module id.\n * @param {boolean} showNewCategory Display the New category tab when selecting random questions.\n */\n static init(\n contextId,\n bankCmId,\n category,\n returnUrl,\n quizCmId,\n showNewCategory = true\n ) {\n const selector = '.menu [data-action=\"addarandomquestion\"], [data-action=\"editrandomquestion\"]';\n document.addEventListener('click', (e) => {\n const trigger = e.target.closest(selector);\n if (!trigger) {\n return;\n }\n e.preventDefault();\n\n if (trigger.dataset.slotid) {\n showNewCategory = false;\n }\n\n ModalAddRandomQuestion.create({\n contextId,\n bankCmId,\n category,\n returnUrl,\n quizCmId,\n showNewCategory,\n title: trigger.dataset.header,\n addOnPage: trigger.dataset.addonpage,\n slotId: trigger.dataset.slotid,\n templateContext: {\n hidden: showNewCategory,\n },\n });\n });\n }\n\n /**\n * Constructor for the Modal.\n *\n * @param {object} root The root jQuery element for the modal\n */\n constructor(root) {\n super(root);\n this.category = null;\n this.returnUrl = null;\n this.quizCmId = null;\n this.loadedForm = false;\n this.slotId = 0;\n this.savedFilterCondition = null;\n }\n\n configure(modalConfig) {\n modalConfig.removeOnClose = true;\n\n this.setCategory(modalConfig.category);\n this.setReturnUrl(modalConfig.returnUrl);\n this.showNewCategory = modalConfig.showNewCategory;\n this.setSlotId(modalConfig.slotId ?? 0);\n this.setSavedFilterCondition(modalConfig.savedFilterCondition ?? null);\n\n super.configure(modalConfig);\n }\n\n /**\n * Set the id of the page that the question should be added to\n * when the user clicks the add to quiz link.\n *\n * @method setAddOnPageId\n * @param {int} id\n */\n setAddOnPageId(id) {\n super.setAddOnPageId(id);\n this.getBody().find(SELECTORS.ADD_ON_PAGE_FORM_ELEMENT).val(id);\n }\n\n /**\n * Set the category for this form. The category is a comma separated\n * category id and category context id.\n *\n * @method setCategory\n * @param {string} category\n */\n setCategory(category) {\n this.category = category;\n }\n\n /**\n * Returns the saved category.\n *\n * @method getCategory\n * @return {string}\n */\n getCategory() {\n return this.category;\n }\n\n /**\n * Set the return URL for the form.\n *\n * @method setReturnUrl\n * @param {string} url\n */\n setReturnUrl(url) {\n this.returnUrl = url;\n }\n\n /**\n * Returns the return URL for the form.\n *\n * @method getReturnUrl\n * @return {string}\n */\n getReturnUrl() {\n return this.returnUrl;\n }\n\n /**\n * Set the ID of the quiz slot, if we are editing an existing random question.\n *\n * @param {Number} slotId\n */\n setSlotId(slotId) {\n this.slotId = slotId;\n }\n\n /**\n * Get the current slot ID.\n *\n * @return {Number}\n */\n getSlotId() {\n return this.slotId;\n }\n\n /**\n * Store the current filterCondition JSON string.\n *\n * @param {String} filterCondition\n */\n setSavedFilterCondition(filterCondition) {\n this.savedFilterCondition = filterCondition;\n }\n\n /**\n * Return the saved filterCondition JSON string.\n *\n * @return {String}\n */\n getSavedFilterCondition() {\n return this.savedFilterCondition;\n }\n\n /**\n * Moves a given form element inside (a child of) a given tab element.\n *\n * Hides the 'legend' (e.g. header) element of the form element because the\n * tab has the name.\n *\n * Moves the submit button into a footer element at the bottom of the form\n * element for styling purposes.\n *\n * @method moveContentIntoTab\n * @param {jquery} tabContent The form element to move into the tab.\n * @param {jquey} tabElement The tab element for the form element to move into.\n */\n moveContentIntoTab(tabContent, tabElement) {\n // Hide the header because the tabs show us which part of the form we're\n // looking at.\n tabContent.find(SELECTORS.FORM_HEADER).addClass('hidden');\n // Move the element inside a tab.\n tabContent.wrap(tabElement);\n }\n\n /**\n * Empty the tab content container and move all tabs from the form into the\n * tab container element.\n *\n * @method moveTabsIntoTabContent\n * @param {jquery} form The form element.\n */\n moveTabsIntoTabContent(form) {\n // Empty it to remove the loading icon.\n const tabContent = this.getBody().find(SELECTORS.TAB_CONTENT).empty();\n // Make sure all tabs are inside the tab content element.\n form.find('[role=\"tabpanel\"]').wrapAll(tabContent);\n }\n\n /**\n * Make sure all of the tabs have a cancel button in their fotter to sit along\n * side the submit button.\n *\n * @method moveCancelButtonToTabs\n * @param {jquey} form The form element.\n */\n moveCancelButtonToTabs(form) {\n const cancelButton = form.find(SELECTORS.CANCEL_BUTTON_ELEMENT).addClass('ms-1');\n const tabFooters = form.find('[data-region=\"footer\"]');\n // Remove the buttons container element.\n cancelButton.closest(SELECTORS.BUTTON_CONTAINER).remove();\n cancelButton.clone().appendTo(tabFooters);\n }\n\n /**\n * Load the add random question form in a fragement and perform some transformation\n * on the HTML to convert it into tabs for rendering in the modal.\n *\n * @method loadForm\n * @return {promise} Resolved with form HTML and JS.\n */\n loadForm() {\n const addonpage = this.getAddOnPageId();\n const returnurl = this.getReturnUrl();\n const quizcmid = this.quizCmId;\n const bankcmid = this.bankCmId;\n const savedfiltercondition = this.getSavedFilterCondition();\n this.setSavedFilterCondition(null);\n\n return Fragment.loadFragment(\n 'mod_quiz',\n 'add_random_question_form',\n this.getContextId(),\n {\n addonpage: addonpage ?? null,\n returnurl,\n quizcmid,\n bankcmid,\n slotid: this.getSlotId(),\n savedfiltercondition,\n }\n )\n .then((html, js) => {\n const form = $(html);\n if (!this.getSlotId()) {\n const existingCategoryTabContent = form.find(SELECTORS.EXISTING_CATEGORY_TAB);\n const existingCategoryTab = this.getBody().find(SELECTORS.EXISTING_CATEGORY_CONTAINER);\n const newCategoryTabContent = form.find(SELECTORS.NEW_CATEGORY_TAB);\n const newCategoryTab = this.getBody().find(SELECTORS.NEW_CATEGORY_CONTAINER);\n\n // Transform the form into tabs for better rendering in the modal.\n this.moveContentIntoTab(existingCategoryTabContent, existingCategoryTab);\n this.moveContentIntoTab(newCategoryTabContent, newCategoryTab);\n this.moveTabsIntoTabContent(form);\n }\n\n Templates.replaceNode(this.getBody().find(SELECTORS.TAB_CONTENT), form, js);\n return;\n })\n .then(() => {\n // Make sure the form change checker is disabled otherwise it'll stop the user from navigating away from the\n // page once the modal is hidden.\n FormChangeChecker.disableAllChecks();\n\n // Add question to quiz.\n this.getBody()[0].addEventListener('click', (e) => {\n const button = e.target.closest(SELECTORS.SUBMIT_BUTTON_ELEMENT);\n if (!button) {\n return;\n }\n e.preventDefault();\n\n // Intercept the submission to adjust the POST params so that the quiz mod id is set and not the bank module id.\n document.querySelector('#questionscontainer input[name=\"cmid\"]').setAttribute('name', this.quizCmId);\n\n // Add Random questions if the add random button was clicked.\n const addRandomButton = e.target.closest(SELECTORS.ADD_RANDOM_BUTTON);\n if (addRandomButton) {\n const randomcount = document.querySelector(SELECTORS.SELECT_NUMBER_TO_ADD).value;\n const filtercondition = document.querySelector(SELECTORS.FILTER_CONDITION_ELEMENT).dataset?.filtercondition;\n\n this.addQuestions(quizcmid, addonpage, randomcount, filtercondition, '', '');\n return;\n }\n // Update the filter condition for the slot if the update button was clicked.\n const updateFilterButton = e.target.closest(SELECTORS.UPDATE_FILTER_BUTTON);\n if (updateFilterButton) {\n const filtercondition = document.querySelector(SELECTORS.FILTER_CONDITION_ELEMENT).dataset?.filtercondition;\n this.updateFilterCondition(quizcmid, this.getSlotId(), filtercondition);\n return;\n }\n // Add new category if the add category button was clicked.\n const addCategoryButton = e.target.closest(SELECTORS.ADD_NEW_CATEGORY_BUTTON);\n if (addCategoryButton) {\n this.addQuestions(\n quizcmid,\n addonpage,\n 1,\n '',\n document.querySelector(SELECTORS.NEW_CATEGORY_ELEMENT).value,\n document.querySelector(SELECTORS.PARENT_CATEGORY_ELEMENT).value\n );\n return;\n }\n });\n\n this.getModal().on('click', SELECTORS.SWITCH_TO_OTHER_BANK, () => {\n this.setSavedFilterCondition(\n document.querySelector(SELECTORS.FILTER_CONDITION_ELEMENT).dataset?.filtercondition\n );\n this.handleSwitchBankContentReload(SELECTORS.BANK_SEARCH)\n .then(function(ModalQuizQuestionBank) {\n $(SELECTORS.BANK_SEARCH)?.on('change', (e) => {\n const bankCmId = $(e.currentTarget).val();\n // Have to recreate the modal as we have already used the body for the switch bank content.\n if (bankCmId > 0) {\n ModalAddRandomQuestion.create({\n 'contextId': ModalQuizQuestionBank.getContextId(),\n 'bankCmId': bankCmId,\n 'category': ModalQuizQuestionBank.getCategory(),\n 'returnUrl': ModalQuizQuestionBank.getReturnUrl(),\n 'quizCmId': ModalQuizQuestionBank.quizCmId,\n 'title': ModalQuizQuestionBank.originalTitle,\n 'addOnPage': ModalQuizQuestionBank.getAddOnPageId(),\n 'templateContext': {hidden: ModalQuizQuestionBank.showNewCategory},\n 'showNewCategory': ModalQuizQuestionBank.showNewCategory,\n 'slotId': ModalQuizQuestionBank.getSlotId(),\n })\n .then(ModalQuizQuestionBank.destroy())\n .catch(Notification.exception);\n }\n });\n return ModalQuizQuestionBank;\n });\n });\n\n this.getModal().on('click', SELECTORS.GO_BACK_BUTTON, (e) => {\n const anchorElement = $(e.currentTarget);\n // Have to recreate the modal as we have already used the body for the switch bank content.\n ModalAddRandomQuestion.create({\n 'contextId': this.getContextId(),\n 'bankCmId': anchorElement.attr('value'),\n 'category': this.getCategory(),\n 'returnUrl': this.getReturnUrl(),\n 'quizCmId': this.quizCmId,\n 'title': this.originalTitle,\n 'addOnPage': this.getAddOnPageId(),\n 'templateContext': {hidden: this.showNewCategory},\n 'showNewCategory': this.showNewCategory,\n 'savedFilterCondition': this.getSavedFilterCondition(),\n 'slotId': this.getSlotId(),\n }).then(this.destroy()).catch(Notification.exception);\n });\n\n this.getModal().on('click', SELECTORS.ANCHOR, (e) => {\n const anchorElement = $(e.currentTarget);\n // Have to recreate the modal as we have already used the body for the switch bank content.\n if (anchorElement.closest('a[' + SELECTORS.NEW_BANKMOD_ID + ']').length) {\n ModalAddRandomQuestion.create({\n 'contextId': this.getContextId(),\n 'bankCmId': anchorElement.attr(SELECTORS.NEW_BANKMOD_ID),\n 'category': this.getCategory(),\n 'returnUrl': this.getReturnUrl(),\n 'quizCmId': this.quizCmId,\n 'title': this.originalTitle,\n 'addOnPage': this.getAddOnPageId(),\n 'templateContext': {hidden: this.showNewCategory},\n 'showNewCategory': this.showNewCategory,\n 'slotId': this.getSlotId(),\n }).then(this.destroy()).catch(Notification.exception);\n }\n });\n })\n .catch(Notification.exception);\n }\n\n /**\n * Call web service function to add random questions\n *\n * @param {number} quizcmid the course module id of the quiz to add questions to.\n * @param {number} addonpage the page where random questions will be added to\n * @param {number} randomcount Number of random questions\n * @param {string} filtercondition Filter condition\n * @param {string} newcategory add new category\n * @param {string} parentcategory parent category of new category\n */\n async addQuestions(\n quizcmid,\n addonpage,\n randomcount,\n filtercondition,\n newcategory,\n parentcategory\n ) {\n // We do not need to resolve this Pending because the form submission will result in a page redirect.\n new Pending('mod-quiz/modal_add_random_questions');\n const call = {\n methodname: 'mod_quiz_add_random_questions',\n args: {\n cmid: quizcmid,\n addonpage,\n randomcount,\n filtercondition,\n newcategory,\n parentcategory,\n }\n };\n try {\n const response = await fetchMany([call])[0];\n const form = document.querySelector(SELECTORS.FORM_ELEMENT);\n const messageInput = form.querySelector(SELECTORS.MESSAGE_INPUT);\n messageInput.value = response.message;\n form.submit();\n } catch (e) {\n Notification.exception(e);\n }\n }\n\n /**\n * Call web service function to update the filter condition for an existing slot.\n *\n * @param {number} quizcmid the course module id of the quiz.\n * @param {number} slotid The slot the random question is in.\n * @param {string} filtercondition The new filter condition.\n */\n async updateFilterCondition(\n quizcmid,\n slotid,\n filtercondition,\n ) {\n // We do not need to resolve this Pending because the form submission will result in a page redirect.\n new Pending('mod-quiz/modal_add_random_questions');\n const call = {\n methodname: 'mod_quiz_update_filter_condition',\n args: {\n cmid: quizcmid,\n slotid,\n filtercondition,\n }\n };\n try {\n const response = await fetchMany([call])[0];\n const form = document.querySelector(SELECTORS.FORM_ELEMENT);\n const messageInput = form.querySelector(SELECTORS.MESSAGE_INPUT);\n messageInput.value = response.message;\n form.submit();\n } catch (e) {\n Notification.exception(e);\n }\n }\n\n /**\n * Override the modal show function to load the form when this modal is first\n * shown.\n *\n * @method show\n */\n show() {\n super.show(this);\n\n if (!this.loadedForm) {\n this.tabHtml = this.getBody();\n this.loadForm(window.location.search);\n this.loadedForm = true;\n }\n }\n}\n\nModalAddRandomQuestion.registerModalType();\n"],"names":["SELECTORS","ANCHOR","EXISTING_CATEGORY_CONTAINER","EXISTING_CATEGORY_TAB","NEW_CATEGORY_CONTAINER","NEW_CATEGORY_TAB","TAB_CONTENT","ADD_ON_PAGE_FORM_ELEMENT","ADD_RANDOM_BUTTON","ADD_NEW_CATEGORY_BUTTON","SUBMIT_BUTTON_ELEMENT","FORM_HEADER","SELECT_NUMBER_TO_ADD","NEW_CATEGORY_ELEMENT","PARENT_CATEGORY_ELEMENT","FILTER_CONDITION_ELEMENT","FORM_ELEMENT","MESSAGE_INPUT","SWITCH_TO_OTHER_BANK","NEW_BANKMOD_ID","BANK_SEARCH","GO_BACK_BUTTON","UPDATE_FILTER_BUTTON","ModalAddRandomQuestion","Modal","contextId","bankCmId","category","returnUrl","quizCmId","showNewCategory","document","addEventListener","e","trigger","target","closest","preventDefault","dataset","slotid","create","title","header","addOnPage","addonpage","slotId","templateContext","hidden","constructor","root","loadedForm","savedFilterCondition","configure","modalConfig","removeOnClose","setCategory","setReturnUrl","setSlotId","setSavedFilterCondition","setAddOnPageId","id","getBody","find","val","getCategory","this","url","getReturnUrl","getSlotId","filterCondition","getSavedFilterCondition","moveContentIntoTab","tabContent","tabElement","addClass","wrap","moveTabsIntoTabContent","form","empty","wrapAll","moveCancelButtonToTabs","cancelButton","CANCEL_BUTTON_ELEMENT","tabFooters","BUTTON_CONTAINER","remove","clone","appendTo","loadForm","getAddOnPageId","returnurl","quizcmid","bankcmid","savedfiltercondition","Fragment","loadFragment","getContextId","then","html","js","existingCategoryTabContent","existingCategoryTab","newCategoryTabContent","newCategoryTab","Templates","replaceNode","FormChangeChecker","disableAllChecks","querySelector","setAttribute","randomcount","value","filtercondition","_document$querySelect","addQuestions","_document$querySelect2","updateFilterCondition","getModal","on","_document$querySelect3","handleSwitchBankContentReload","ModalQuizQuestionBank","currentTarget","originalTitle","destroy","catch","Notification","exception","anchorElement","attr","length","newcategory","parentcategory","Pending","call","methodname","args","cmid","response","message","submit","show","tabHtml","window","location","search","registerModalType"],"mappings":"g5DAgCMA,UAAY,CACdC,OAAQ,UACRC,4BAA6B,8CAC7BC,sBAAuB,6BACvBC,uBAAwB,yCACxBC,iBAAkB,wBAClBC,YAAa,8BACbC,yBAA0B,qBAC1BC,kBAAmB,yCACnBC,wBAAyB,2CACzBC,sBAAuB,wHAGvBC,YAAa,SACbC,qBAAsB,mBACtBC,qBAAsB,gBACtBC,wBAAyB,kBACzBC,yBAA0B,yBAC1BC,aAAc,4BACdC,cAAe,mBACfC,qBAAsB,6CACtBC,eAAgB,gBAChBC,YAAa,eACbC,eAAgB,gCAChBC,qBAAsB,6CAGLC,+BAA+BC,wCAe5CC,UACAC,SACAC,SACAC,UACAC,cACAC,2EAGAC,SAASC,iBAAiB,SAAUC,UAC1BC,QAAUD,EAAEE,OAAOC,QAFZ,gFAGRF,UAGLD,EAAEI,iBAEEH,QAAQI,QAAQC,SAChBT,iBAAkB,GAGtBP,uBAAuBiB,OAAO,CAC1Bf,UAAAA,UACAC,SAAAA,SACAC,SAAAA,SACAC,UAAAA,UACAC,SAAAA,SACAC,gBAAAA,gBACAW,MAAOP,QAAQI,QAAQI,OACvBC,UAAWT,QAAQI,QAAQM,UAC3BC,OAAQX,QAAQI,QAAQC,OACxBO,gBAAiB,CACbC,OAAQjB,uBAWxBkB,YAAYC,YACFA,WACDtB,SAAW,UACXC,UAAY,UACZC,SAAW,UACXqB,YAAa,OACbL,OAAS,OACTM,qBAAuB,KAGhCC,UAAUC,2DACNA,YAAYC,eAAgB,OAEvBC,YAAYF,YAAY1B,eACxB6B,aAAaH,YAAYzB,gBACzBE,gBAAkBuB,YAAYvB,qBAC9B2B,sCAAUJ,YAAYR,0DAAU,QAChCa,sDAAwBL,YAAYF,4EAAwB,YAE3DC,UAAUC,aAUpBM,eAAeC,UACLD,eAAeC,SAChBC,UAAUC,KAAK9D,UAAUO,0BAA0BwD,IAAIH,IAUhEL,YAAY5B,eACHA,SAAWA,SASpBqC,qBACWC,KAAKtC,SAShB6B,aAAaU,UACJtC,UAAYsC,IASrBC,sBACWF,KAAKrC,UAQhB6B,UAAUZ,aACDA,OAASA,OAQlBuB,mBACWH,KAAKpB,OAQhBa,wBAAwBW,sBACflB,qBAAuBkB,gBAQhCC,iCACWL,KAAKd,qBAgBhBoB,mBAAmBC,WAAYC,YAG3BD,WAAWV,KAAK9D,UAAUW,aAAa+D,SAAS,UAEhDF,WAAWG,KAAKF,YAUpBG,uBAAuBC,YAEbL,WAAaP,KAAKJ,UAAUC,KAAK9D,UAAUM,aAAawE,QAE9DD,KAAKf,KAAK,qBAAqBiB,QAAQP,YAU3CQ,uBAAuBH,YACbI,aAAeJ,KAAKf,KAAK9D,UAAUkF,uBAAuBR,SAAS,QACnES,WAAaN,KAAKf,KAAK,0BAE7BmB,aAAa7C,QAAQpC,UAAUoF,kBAAkBC,SACjDJ,aAAaK,QAAQC,SAASJ,YAUlCK,iBACU5C,UAAYqB,KAAKwB,iBACjBC,UAAYzB,KAAKE,eACjBwB,SAAW1B,KAAKpC,SAChB+D,SAAW3B,KAAKvC,SAChBmE,qBAAuB5B,KAAKK,sCAC7BZ,wBAAwB,MAEtBoC,SAASC,aACZ,WACA,2BACA9B,KAAK+B,eACL,CACIpD,UAAWA,MAAAA,UAAAA,UAAa,KACxB8C,UAAAA,UACAC,SAAAA,SACAC,SAAAA,SACArD,OAAQ0B,KAAKG,YACbyB,qBAAAA,uBAGHI,MAAK,CAACC,KAAMC,YACHtB,MAAO,mBAAEqB,UACVjC,KAAKG,YAAa,OACbgC,2BAA6BvB,KAAKf,KAAK9D,UAAUG,uBACjDkG,oBAAsBpC,KAAKJ,UAAUC,KAAK9D,UAAUE,6BACpDoG,sBAAwBzB,KAAKf,KAAK9D,UAAUK,kBAC5CkG,eAAiBtC,KAAKJ,UAAUC,KAAK9D,UAAUI,6BAGhDmE,mBAAmB6B,2BAA4BC,0BAC/C9B,mBAAmB+B,sBAAuBC,qBAC1C3B,uBAAuBC,MAGhC2B,UAAUC,YAAYxC,KAAKJ,UAAUC,KAAK9D,UAAUM,aAAcuE,KAAMsB,OAG3EF,MAAK,KAGFS,kBAAkBC,wBAGb9C,UAAU,GAAG7B,iBAAiB,SAAUC,QAC1BA,EAAEE,OAAOC,QAAQpC,UAAUU,8BAI1CuB,EAAEI,iBAGFN,SAAS6E,cAAc,0CAA0CC,aAAa,OAAQ5C,KAAKpC,aAGnEI,EAAEE,OAAOC,QAAQpC,UAAUQ,mBAC9B,iCACXsG,YAAc/E,SAAS6E,cAAc5G,UAAUY,sBAAsBmG,MACrEC,8CAAkBjF,SAAS6E,cAAc5G,UAAUe,0BAA0BuB,gDAA3D2E,sBAAoED,iCAEvFE,aAAavB,SAAU/C,UAAWkE,YAAaE,gBAAiB,GAAI,OAIlD/E,EAAEE,OAAOC,QAAQpC,UAAUsB,sBAC9B,kCACd0F,+CAAkBjF,SAAS6E,cAAc5G,UAAUe,0BAA0BuB,iDAA3D6E,uBAAoEH,iCACvFI,sBAAsBzB,SAAU1B,KAAKG,YAAa4C,iBAIjC/E,EAAEE,OAAOC,QAAQpC,UAAUS,+BAE5CyG,aACDvB,SACA/C,UACA,EACA,GACAb,SAAS6E,cAAc5G,UAAUa,sBAAsBkG,MACvDhF,SAAS6E,cAAc5G,UAAUc,yBAAyBiG,eAMjEM,WAAWC,GAAG,QAAStH,UAAUkB,sBAAsB,qCACnDwC,uDACD3B,SAAS6E,cAAc5G,UAAUe,0BAA0BuB,iDAA3DiF,uBAAoEP,sBAEnEQ,8BAA8BxH,UAAUoB,aACxC6E,MAAK,SAASwB,oEACTzH,UAAUoB,+BAAckG,GAAG,UAAWrF,UAC9BP,UAAW,mBAAEO,EAAEyF,eAAe3D,MAEhCrC,SAAW,GACXH,uBAAuBiB,OAAO,WACbiF,sBAAsBzB,wBACvBtE,kBACA+F,sBAAsBzD,wBACrByD,sBAAsBtD,wBACvBsD,sBAAsB5F,eACzB4F,sBAAsBE,wBAClBF,sBAAsBhC,iCAChB,CAAC1C,OAAQ0E,sBAAsB3F,iCAC/B2F,sBAAsB3F,uBAC/B2F,sBAAsBrD,cAEnC6B,KAAKwB,sBAAsBG,WAC3BC,MAAMC,aAAaC,cAGrBN,iCAIdJ,WAAWC,GAAG,QAAStH,UAAUqB,gBAAiBY,UAC7C+F,eAAgB,mBAAE/F,EAAEyF,eAE1BnG,uBAAuBiB,OAAO,WACbyB,KAAK+B,wBACNgC,cAAcC,KAAK,kBACnBhE,KAAKD,wBACJC,KAAKE,wBACNF,KAAKpC,eACRoC,KAAK0D,wBACD1D,KAAKwB,iCACC,CAAC1C,OAAQkB,KAAKnC,iCACdmC,KAAKnC,qCACAmC,KAAKK,iCACnBL,KAAKG,cAChB6B,KAAKhC,KAAK2D,WAAWC,MAAMC,aAAaC,mBAG1CV,WAAWC,GAAG,QAAStH,UAAUC,QAASgC,UACrC+F,eAAgB,mBAAE/F,EAAEyF,eAEtBM,cAAc5F,QAAQ,KAAOpC,UAAUmB,eAAiB,KAAK+G,QAC7D3G,uBAAuBiB,OAAO,WACbyB,KAAK+B,wBACNgC,cAAcC,KAAKjI,UAAUmB,yBAC7B8C,KAAKD,wBACJC,KAAKE,wBACNF,KAAKpC,eACRoC,KAAK0D,wBACD1D,KAAKwB,iCACC,CAAC1C,OAAQkB,KAAKnC,iCACdmC,KAAKnC,uBACdmC,KAAKG,cAChB6B,KAAKhC,KAAK2D,WAAWC,MAAMC,aAAaC,iBAItDF,MAAMC,aAAaC,8BAcxBpC,SACA/C,UACAkE,YACAE,gBACAmB,YACAC,oBAGIC,iBAAQ,6CACNC,KAAO,CACTC,WAAY,gCACZC,KAAM,CACFC,KAAM9C,SACN/C,UAAAA,UACAkE,YAAAA,YACAE,gBAAAA,gBACAmB,YAAAA,YACAC,eAAAA,2BAIEM,eAAiB,cAAU,CAACJ,OAAO,GACnCzD,KAAO9C,SAAS6E,cAAc5G,UAAUgB,cACzB6D,KAAK+B,cAAc5G,UAAUiB,eACrC8F,MAAQ2B,SAASC,QAC9B9D,KAAK+D,SACP,MAAO3G,GACL6F,aAAaC,UAAU9F,gCAY3B0D,SACApD,OACAyE,qBAGIqB,iBAAQ,6CACNC,KAAO,CACTC,WAAY,mCACZC,KAAM,CACFC,KAAM9C,SACNpD,OAAAA,OACAyE,gBAAAA,4BAIE0B,eAAiB,cAAU,CAACJ,OAAO,GACnCzD,KAAO9C,SAAS6E,cAAc5G,UAAUgB,cACzB6D,KAAK+B,cAAc5G,UAAUiB,eACrC8F,MAAQ2B,SAASC,QAC9B9D,KAAK+D,SACP,MAAO3G,GACL6F,aAAaC,UAAU9F,IAU/B4G,aACUA,KAAK5E,MAENA,KAAKf,kBACD4F,QAAU7E,KAAKJ,eACf2B,SAASuD,OAAOC,SAASC,aACzB/F,YAAa,mEAldT3B,8BACH,qDADGA,kCAEC,sCAqdtBA,uBAAuB2H"}PK9B\x[..(amd/build/submission_confirmation.min.jsnu[define("mod_quiz/submission_confirmation",["exports","core/notification","core/prefetch","core/templates","core/str"],(function(_exports,_notification,_prefetch,_templates,_str){function _interopRequireDefault(obj){return obj&&obj.__esModule?obj:{default:obj}} /** * A javascript module to handle submission confirmation for quiz. * * @module mod_quiz/submission_confirmation * @copyright 2022 Huong Nguyen * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @since 4.1 */Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.init=void 0,_prefetch=_interopRequireDefault(_prefetch),_templates=_interopRequireDefault(_templates);const SELECTOR_attemptSubmitButton=".path-mod-quiz .btn-finishattempt button",SELECTOR_attemptSubmitForm="form#frm-finishattempt",TEMPLATES_submissionConfirmation="mod_quiz/submission_confirmation";_exports.init=unAnsweredQuestions=>{_prefetch.default.prefetchStrings("core",["submit"]),_prefetch.default.prefetchStrings("core_admin",["confirmation"]),_prefetch.default.prefetchStrings("quiz",["submitallandfinish","submission_confirmation"]),_prefetch.default.prefetchTemplate(TEMPLATES_submissionConfirmation),(unAnsweredQuestions=>{const submitAction=document.querySelector(SELECTOR_attemptSubmitButton);submitAction&&submitAction.addEventListener("click",(async e=>{e.preventDefault();try{await(0,_notification.saveCancelPromise)((0,_str.getString)("submission_confirmation","quiz"),_templates.default.render(TEMPLATES_submissionConfirmation,{hasunanswered:unAnsweredQuestions>0,totalunanswered:unAnsweredQuestions}),(0,_str.getString)("submitallandfinish","quiz")),submitAction.closest(SELECTOR_attemptSubmitForm).submit()}catch{return}}))})(unAnsweredQuestions)}})); //# sourceMappingURL=submission_confirmation.min.js.mapPK9B\>N'amd/build/add_question_modal.min.js.mapnu[{"version":3,"file":"add_question_modal.min.js","sources":["../src/add_question_modal.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 add random question modal.\n *\n * @module mod_quiz/add_question_modal\n * @copyright 2023 Andrew Lyons \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';\n\nexport default class AddQuestionModal extends Modal {\n configure(modalConfig) {\n // Add question modals are always large.\n modalConfig.large = true;\n\n // Always show on creation.\n modalConfig.show = true;\n modalConfig.removeOnClose = true;\n\n // Apply question modal configuration.\n this.setContextId(modalConfig.contextId);\n this.setAddOnPageId(modalConfig.addOnPage);\n\n // Store the quiz module id for when we need to POST to the quiz.\n // This is because the URL cmid param will change during filter operations as we will be in another bank context.\n this.quizCmId = modalConfig.quizCmId;\n this.bankCmId = modalConfig.bankCmId;\n\n // Store the original title of the modal, so we can revert back to it once we have switched to another bank.\n this.originalTitle = modalConfig.title;\n\n // Apply standard configuration.\n super.configure(modalConfig);\n }\n\n constructor(root) {\n super(root);\n\n this.contextId = null;\n this.addOnPageId = null;\n }\n\n /**\n * Save the Moodle context id that the question bank is being\n * rendered in.\n *\n * @method setContextId\n * @param {Number} id\n */\n setContextId(id) {\n this.contextId = id;\n }\n\n /**\n * Retrieve the saved Moodle context id.\n *\n * @method getContextId\n * @return {Number}\n */\n getContextId() {\n return this.contextId;\n }\n\n /**\n * Set the id of the page that the question should be added to\n * when the user clicks the add to quiz link.\n *\n * @method setAddOnPageId\n * @param {Number} id\n */\n setAddOnPageId(id) {\n this.addOnPageId = id;\n }\n\n /**\n * Returns the saved page id for the question to be added to.\n *\n * @method getAddOnPageId\n * @return {Number}\n */\n getAddOnPageId() {\n return this.addOnPageId;\n }\n\n /**\n * Update the modal with a list of banks to switch to and enhance the standard selects to Autocomplete fields.\n *\n * @param {String} Selector for the original select element.\n * @return {Promise} Modal.\n */\n async handleSwitchBankContentReload(Selector) {\n this.setTitle(getString('selectquestionbank', 'mod_quiz'));\n\n // Create a 'Go back' button and set it in the footer.\n const el = document.createElement('button');\n el.classList.add('btn', 'btn-primary');\n el.textContent = await getString('gobacktoquiz', 'mod_quiz');\n el.setAttribute('data-action', 'go-back');\n el.setAttribute('value', this.bankCmId);\n this.setFooter(el);\n\n this.setBody(\n Fragment.loadFragment(\n 'mod_quiz',\n 'switch_question_bank',\n this.getContextId(),\n {\n 'quizcmid': this.quizCmId,\n 'bankcmid': this.bankCmId,\n })\n );\n const placeholder = await getString('searchbyname', 'mod_quiz');\n await this.getBodyPromise();\n await AutoComplete.enhance(\n Selector,\n false,\n 'core_question/question_banks_datasource',\n placeholder,\n false,\n true,\n '',\n true\n );\n\n // Hide the selection element as we don't need it.\n document.querySelector('.search-banks .form-autocomplete-selection')?.classList.add('d-none');\n\n return this;\n }\n}\n"],"names":["AddQuestionModal","Modal","configure","modalConfig","large","show","removeOnClose","setContextId","contextId","setAddOnPageId","addOnPage","quizCmId","bankCmId","originalTitle","title","constructor","root","addOnPageId","id","getContextId","this","getAddOnPageId","Selector","setTitle","el","document","createElement","classList","add","textContent","setAttribute","setFooter","setBody","Fragment","loadFragment","placeholder","getBodyPromise","AutoComplete","enhance","querySelector"],"mappings":";;;;;;;61BA4BqBA,yBAAyBC,eAC1CC,UAAUC,aAENA,YAAYC,OAAQ,EAGpBD,YAAYE,MAAO,EACnBF,YAAYG,eAAgB,OAGvBC,aAAaJ,YAAYK,gBACzBC,eAAeN,YAAYO,gBAI3BC,SAAWR,YAAYQ,cACvBC,SAAWT,YAAYS,cAGvBC,cAAgBV,YAAYW,YAG3BZ,UAAUC,aAGpBY,YAAYC,YACFA,WAEDR,UAAY,UACZS,YAAc,KAUvBV,aAAaW,SACJV,UAAYU,GASrBC,sBACWC,KAAKZ,UAUhBC,eAAeS,SACND,YAAcC,GASvBG,wBACWD,KAAKH,gDASoBK,yCAC3BC,UAAS,kBAAU,qBAAsB,mBAGxCC,GAAKC,SAASC,cAAc,UAClCF,GAAGG,UAAUC,IAAI,MAAO,eACxBJ,GAAGK,kBAAoB,kBAAU,eAAgB,YACjDL,GAAGM,aAAa,cAAe,WAC/BN,GAAGM,aAAa,QAASV,KAAKR,eACzBmB,UAAUP,SAEVQ,QACDC,SAASC,aACL,WACA,uBACAd,KAAKD,eACL,UACgBC,KAAKT,kBACLS,KAAKR,kBAGvBuB,kBAAoB,kBAAU,eAAgB,yBAC9Cf,KAAKgB,uBACLC,0BAAaC,QACfhB,UACA,EACA,0CACAa,aACA,GACA,EACA,IACA,iCAIJV,SAASc,cAAc,sGAA+CZ,UAAUC,IAAI,UAE7ER"}PK9B\"WYamd/build/repaginate.min.jsnu[define("mod_quiz/repaginate",["exports","core/modal"],(function(_exports,_modal){var obj; /** * Initialise the repaginate dialogue on quiz editing page. * * @module mod_quiz/repaginate * @copyright 2019 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.init=void 0,_modal=(obj=_modal)&&obj.__esModule?obj:{default:obj};_exports.init=()=>{document.addEventListener("click",(event=>{const repaginateCommand=event.target.closest("#repaginatecommand");repaginateCommand&&(event.preventDefault(),_modal.default.create({title:repaginateCommand.dataset.header,body:repaginateCommand.dataset.form,large:!1,show:!0}))}))}})); //# sourceMappingURL=repaginate.min.js.mapPK9B\E/R.R.-amd/build/modal_quiz_question_bank.min.js.mapnu[{"version":3,"file":"modal_quiz_question_bank.min.js","sources":["../src/modal_quiz_question_bank.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 question bank modal.\n *\n * @module mod_quiz/modal_quiz_question_bank\n * @copyright 2018 Ryan Wyllie \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport Modal from './add_question_modal';\nimport * as Fragment from 'core/fragment';\nimport * as FormChangeChecker from 'core_form/changechecker';\nimport * as ModalEvents from 'core/modal_events';\nimport * as Notification from 'core/notification';\n\nconst SELECTORS = {\n ADD_TO_QUIZ_CONTAINER: 'td.addtoquizaction',\n ANCHOR: 'a[href]',\n PREVIEW_CONTAINER: 'td.previewquestionaction',\n ADD_QUESTIONS_FORM: 'form#questionsubmit',\n SORTERS: '.sorters',\n SWITCH_TO_OTHER_BANK: 'button[data-action=\"switch-question-bank\"]',\n NEW_BANKMOD_ID: 'data-newmodid',\n BANK_SEARCH: '#searchbanks',\n GO_BACK_BUTTON: 'button[data-action=\"go-back\"]',\n ADD_ON_PAGE_FORM_ELEMENT: 'input[name=\"addonpage\"]',\n CMID_FORM_ELEMENT: 'form#questionsubmit input[name=\"cmid\"]',\n};\n\nexport default class ModalQuizQuestionBank extends Modal {\n static TYPE = 'mod_quiz-quiz-question-bank';\n\n /**\n * Create the question bank modal.\n *\n * @param {Number} contextId Current module context id.\n * @param {Number} bankCmId Current question bank course module id.\n * @param {Number} quizCmId Current quiz course module id.\n */\n static init(contextId, bankCmId, quizCmId) {\n const selector = '.menu [data-action=\"questionbank\"]';\n document.addEventListener('click', (e) => {\n const trigger = e.target.closest(selector);\n if (!trigger) {\n return;\n }\n e.preventDefault();\n\n ModalQuizQuestionBank.create({\n contextId,\n quizCmId,\n bankCmId,\n title: trigger.dataset.header,\n addOnPage: trigger.dataset.addonpage,\n templateContext: {\n hidden: true,\n },\n large: true,\n });\n });\n }\n\n /**\n * Override the parent show function.\n *\n * Reload the body contents when the modal is shown. The current\n * window URL is used to inform the new content that should be\n * displayed.\n *\n * @method show\n * @return {void}\n */\n show() {\n this.reloadBodyContent(window.location.search);\n return super.show(this);\n }\n\n /**\n * Replaces the current body contents with a new version of the question\n * bank.\n *\n * The contents of the question bank are generated using the provided\n * query string.\n *\n * @method reloadBodyContent\n * @param {string} querystring URL encoded string.\n */\n reloadBodyContent(querystring) {\n // Load the question bank fragment to be displayed in the modal and hide the 'go back' button.\n this.hideFooter();\n this.setTitle(this.originalTitle);\n this.setBody(Fragment.loadFragment(\n 'mod_quiz',\n 'quiz_question_bank',\n this.getContextId(),\n {\n querystring,\n quizcmid: this.quizCmId,\n bankcmid: this.bankCmId,\n }\n ));\n }\n\n /**\n * Update the URL of the anchor element that the user clicked on to make\n * sure that the question is added to the correct page.\n *\n * @method handleAddToQuizEvent\n * @param {event} e A JavaScript event\n * @param {object} anchorElement The anchor element that was triggered\n */\n handleAddToQuizEvent(e, anchorElement) {\n // If the user clicks the plus icon to add the question to the page\n // directly then we need to intercept the click in order to adjust the\n // href and include the correct add on page id and cmid before the page is\n // redirected.\n const href = new URL(anchorElement.getAttribute('href'));\n href.searchParams.set('addonpage', this.getAddOnPageId());\n href.searchParams.set('cmid', this.quizCmId);\n anchorElement.setAttribute('href', href);\n }\n\n /**\n * Set up all of the event handling for the modal.\n *\n * @method registerEventListeners\n */\n registerEventListeners() {\n // Apply parent event listeners.\n super.registerEventListeners(this);\n\n this.getModal().on('submit', SELECTORS.ADD_QUESTIONS_FORM, (e) => {\n // If the user clicks on the \"Add selected questions to the quiz\" button to add some questions to the page\n // then we need to intercept the submit in order to include the correct \"add on page id\"\n // and the quizmod id before the form is submitted.\n const formElement = e.currentTarget;\n document.querySelector(SELECTORS.ADD_ON_PAGE_FORM_ELEMENT).setAttribute('value', this.getAddOnPageId());\n\n // We also need to set the form cmid & action as the quiz modid as this could be coming from a module that isn't a quiz.\n document.querySelector(SELECTORS.CMID_FORM_ELEMENT).setAttribute('value', this.quizCmId);\n const actionUrl = new URL(formElement.getAttribute('action'));\n actionUrl.searchParams.set('cmid', this.quizCmId);\n formElement.setAttribute('action', actionUrl.toString());\n });\n\n this.getModal().on('click', SELECTORS.SWITCH_TO_OTHER_BANK, () => {\n this.handleSwitchBankContentReload(SELECTORS.BANK_SEARCH)\n .then(function(ModalQuizQuestionBank) {\n document.querySelector(SELECTORS.BANK_SEARCH)?.addEventListener('change', (e) => {\n const bankCmId = e.currentTarget.value;\n if (bankCmId > 0) {\n ModalQuizQuestionBank.bankCmId = bankCmId;\n ModalQuizQuestionBank.reloadBodyContent(window.location.search);\n }\n });\n document.querySelector(SELECTORS.GO_BACK_BUTTON).addEventListener('click', (e) => {\n ModalQuizQuestionBank.bankCmId = e.currentTarget.value;\n ModalQuizQuestionBank.reloadBodyContent(window.location.search);\n });\n }\n )\n .catch(Notification.exception);\n });\n\n this.getModal().on('click', SELECTORS.ANCHOR, (e) => {\n const anchorElement = e.currentTarget;\n\n // If the anchor element was the add to quiz link.\n if (anchorElement.closest(SELECTORS.ADD_TO_QUIZ_CONTAINER)) {\n this.handleAddToQuizEvent(e, anchorElement);\n return;\n }\n\n // If the anchor element was a preview question link.\n if (anchorElement.closest(SELECTORS.PREVIEW_CONTAINER)) {\n return;\n }\n\n // Sorting links have their own handler.\n if (anchorElement.closest(SELECTORS.SORTERS)) {\n return;\n }\n\n if (anchorElement.closest('a[' + SELECTORS.NEW_BANKMOD_ID + ']')) {\n this.bankCmId = anchorElement.getAttribute(SELECTORS.NEW_BANKMOD_ID);\n\n // We need to clear the filter as we are about to reload the content.\n const url = new URL(location.href);\n url.searchParams.delete('filter');\n history.pushState({}, '', url);\n }\n\n // Anything else means reload the pop-up contents.\n e.preventDefault();\n this.reloadBodyContent(anchorElement.search);\n });\n\n // Disable the form change checker when the body is rendered.\n this.getRoot().on(ModalEvents.bodyRendered, () => {\n // Make sure the form change checker is disabled otherwise it'll stop the user from navigating away from the\n // page once the modal is hidden.\n FormChangeChecker.disableAllChecks();\n });\n }\n}\n\nModalQuizQuestionBank.registerModalType();\n"],"names":["SELECTORS","ModalQuizQuestionBank","Modal","contextId","bankCmId","quizCmId","document","addEventListener","e","trigger","target","closest","preventDefault","create","title","dataset","header","addOnPage","addonpage","templateContext","hidden","large","show","reloadBodyContent","window","location","search","super","this","querystring","hideFooter","setTitle","originalTitle","setBody","Fragment","loadFragment","getContextId","quizcmid","bankcmid","handleAddToQuizEvent","anchorElement","href","URL","getAttribute","searchParams","set","getAddOnPageId","setAttribute","registerEventListeners","getModal","on","formElement","currentTarget","querySelector","actionUrl","toString","handleSwitchBankContentReload","then","value","catch","Notification","exception","url","delete","history","pushState","getRoot","ModalEvents","bodyRendered","FormChangeChecker","disableAllChecks","registerModalType"],"mappings":"6iDA6BMA,gCACqB,qBADrBA,iBAEM,UAFNA,4BAGiB,2BAHjBA,6BAIkB,sBAJlBA,kBAKO,WALPA,+BAMoB,6CANpBA,yBAOc,gBAPdA,sBAQW,eARXA,yBASc,gCATdA,mCAUwB,0BAVxBA,4BAWiB,+CAGFC,8BAA8BC,wCAUnCC,UAAWC,SAAUC,UAE7BC,SAASC,iBAAiB,SAAUC,UAC1BC,QAAUD,EAAEE,OAAOC,QAFZ,sCAGRF,UAGLD,EAAEI,iBAEFX,sBAAsBY,OAAO,CACzBV,UAAAA,UACAE,SAAAA,SACAD,SAAAA,SACAU,MAAOL,QAAQM,QAAQC,OACvBC,UAAWR,QAAQM,QAAQG,UAC3BC,gBAAiB,CACbC,QAAQ,GAEZC,OAAO,QAenBC,mBACSC,kBAAkBC,OAAOC,SAASC,QAChCC,MAAML,KAAKM,MAatBL,kBAAkBM,kBAETC,kBACAC,SAASH,KAAKI,oBACdC,QAAQC,SAASC,aAClB,WACA,qBACAP,KAAKQ,eACL,CACIP,YAAAA,YACAQ,SAAUT,KAAKvB,SACfiC,SAAUV,KAAKxB,YAa3BmC,qBAAqB/B,EAAGgC,qBAKdC,KAAO,IAAIC,IAAIF,cAAcG,aAAa,SAChDF,KAAKG,aAAaC,IAAI,YAAajB,KAAKkB,kBACxCL,KAAKG,aAAaC,IAAI,OAAQjB,KAAKvB,UACnCmC,cAAcO,aAAa,OAAQN,MAQvCO,+BAEUA,uBAAuBpB,WAExBqB,WAAWC,GAAG,SAAUlD,8BAA+BQ,UAIlD2C,YAAc3C,EAAE4C,cACtB9C,SAAS+C,cAAcrD,oCAAoC+C,aAAa,QAASnB,KAAKkB,kBAGtFxC,SAAS+C,cAAcrD,6BAA6B+C,aAAa,QAASnB,KAAKvB,gBACzEiD,UAAY,IAAIZ,IAAIS,YAAYR,aAAa,WACnDW,UAAUV,aAAaC,IAAI,OAAQjB,KAAKvB,UACxC8C,YAAYJ,aAAa,SAAUO,UAAUC,oBAG5CN,WAAWC,GAAG,QAASlD,gCAAgC,UACnDwD,8BAA8BxD,uBAC9ByD,MAAK,SAASxD,+EACPK,SAAS+C,cAAcrD,+EAAwBO,iBAAiB,UAAWC,UACjEJ,SAAWI,EAAE4C,cAAcM,MAC7BtD,SAAW,IACXH,sBAAsBG,SAAWA,SACjCH,sBAAsBsB,kBAAkBC,OAAOC,SAASC,YAGhEpB,SAAS+C,cAAcrD,0BAA0BO,iBAAiB,SAAUC,IACxEP,sBAAsBG,SAAWI,EAAE4C,cAAcM,MACjDzD,sBAAsBsB,kBAAkBC,OAAOC,SAASC,cAInEiC,MAAMC,aAAaC,mBAGvBZ,WAAWC,GAAG,QAASlD,kBAAmBQ,UACrCgC,cAAgBhC,EAAE4C,iBAGpBZ,cAAc7B,QAAQX,sCACjBuC,qBAAqB/B,EAAGgC,wBAK7BA,cAAc7B,QAAQX,+BAKtBwC,cAAc7B,QAAQX,uBAItBwC,cAAc7B,QAAQ,KAAOX,yBAA2B,KAAM,MACzDI,SAAWoC,cAAcG,aAAa3C,gCAGrC8D,IAAM,IAAIpB,IAAIjB,SAASgB,MAC7BqB,IAAIlB,aAAamB,OAAO,UACxBC,QAAQC,UAAU,GAAI,GAAIH,KAI9BtD,EAAEI,sBACGW,kBAAkBiB,cAAcd,iBAIpCwC,UAAUhB,GAAGiB,YAAYC,cAAc,KAGxCC,kBAAkBC,+MA5KTrE,6BACH,+BAgLlBA,sBAAsBsE"}PK9B\$ĸ #amd/build/add_question_modal.min.jsnu[define("mod_quiz/add_question_modal",["exports","core/modal","core/fragment","core/str","core/form-autocomplete"],(function(_exports,_modal,Fragment,_str,_formAutocomplete){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}} /** * Contain the logic for the add random question modal. * * @module mod_quiz/add_question_modal * @copyright 2023 Andrew Lyons * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */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);class AddQuestionModal extends _modal.default{configure(modalConfig){modalConfig.large=!0,modalConfig.show=!0,modalConfig.removeOnClose=!0,this.setContextId(modalConfig.contextId),this.setAddOnPageId(modalConfig.addOnPage),this.quizCmId=modalConfig.quizCmId,this.bankCmId=modalConfig.bankCmId,this.originalTitle=modalConfig.title,super.configure(modalConfig)}constructor(root){super(root),this.contextId=null,this.addOnPageId=null}setContextId(id){this.contextId=id}getContextId(){return this.contextId}setAddOnPageId(id){this.addOnPageId=id}getAddOnPageId(){return this.addOnPageId}async handleSwitchBankContentReload(Selector){var _document$querySelect;this.setTitle((0,_str.getString)("selectquestionbank","mod_quiz"));const el=document.createElement("button");el.classList.add("btn","btn-primary"),el.textContent=await(0,_str.getString)("gobacktoquiz","mod_quiz"),el.setAttribute("data-action","go-back"),el.setAttribute("value",this.bankCmId),this.setFooter(el),this.setBody(Fragment.loadFragment("mod_quiz","switch_question_bank",this.getContextId(),{quizcmid:this.quizCmId,bankcmid:this.bankCmId}));const placeholder=await(0,_str.getString)("searchbyname","mod_quiz");return await this.getBodyPromise(),await _formAutocomplete.default.enhance(Selector,!1,"core_question/question_banks_datasource",placeholder,!1,!0,"",!0),null===(_document$querySelect=document.querySelector(".search-banks .form-autocomplete-selection"))||void 0===_document$querySelect||_document$querySelect.classList.add("d-none"),this}}return _exports.default=AddQuestionModal,_exports.default})); //# sourceMappingURL=add_question_modal.min.js.mapPK9B\Qc;;#amd/build/preflightcheck.min.js.mapnu[{"version":3,"file":"preflightcheck.min.js","sources":["../src/preflightcheck.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 manages the confirmation pop-up (also called the pre-flight check)\n * that is sometimes shown when a use clicks the start attempt button.\n *\n * This is also responsible for opening the pop-up window, if the quiz requires to be in one.\n *\n * @module mod_quiz/preflightcheck\n * @copyright 2016 The Open University\n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n * @since 3.1\n */\ndefine(['jquery', 'core/yui', 'core_form/changechecker'], function($, Y, FormChangeChecker) {\n\n /**\n * @alias module:mod_quiz/preflightcheck\n */\n var t = {\n confirmDialogue: null,\n\n /**\n * Initialise the start attempt button.\n *\n * @param {String} startButton the id of the start attempt button that we will be enhancing.\n * @param {String} confirmationTitle the title of the dialogue.\n * @param {String} confirmationForm selector for the confirmation form to show in the dialogue.\n * @param {String} popupoptions If not null, the quiz should be launced in a pop-up.\n */\n init: function(startButton, confirmationTitle, confirmationForm, popupoptions) {\n var finalStartButton = startButton;\n\n Y.use('moodle-core-notification', function() {\n if (Y.one(confirmationForm)) {\n t.confirmDialogue = new M.core.dialogue({\n headerContent: confirmationTitle,\n bodyContent: Y.one(confirmationForm),\n draggable: true,\n visible: false,\n center: true,\n modal: true,\n width: null,\n extraClasses: ['mod_quiz_preflight_popup']\n });\n\n Y.one(startButton).on('click', t.displayDialogue);\n Y.one('#id_cancel').on('click', t.hideDialogue);\n\n finalStartButton = t.confirmDialogue.get('boundingBox').one('[name=\"submitbutton\"]');\n }\n\n if (popupoptions) {\n Y.one(finalStartButton).on('click', t.launchQuizPopup, t, popupoptions);\n }\n });\n },\n\n /**\n * Display the dialogue.\n * @param {Y.EventFacade} e the event being responded to, if any.\n */\n displayDialogue: function(e) {\n if (e) {\n e.halt();\n }\n t.confirmDialogue.show();\n },\n\n /**\n * Hide the dialogue.\n * @param {Y.EventFacade} e the event being responded to, if any.\n */\n hideDialogue: function(e) {\n if (e) {\n e.halt();\n }\n t.confirmDialogue.hide(e);\n },\n\n /**\n * Event handler for the quiz start attempt button.\n * @param {Event} e the event being responded to\n * @param {Object} popupoptions\n */\n launchQuizPopup: function(e, popupoptions) {\n e.halt();\n Y.use('io-form', function() {\n var form = e.target.ancestor('form');\n\n FormChangeChecker.resetFormDirtyState(form.getDOMNode());\n window.openpopup(e, {\n url: form.get('action') + '?' + Y.IO.stringify(form).replace(/\\bcancel=/, 'x='),\n windowname: 'quizpopup',\n options: popupoptions,\n fullscreen: true,\n });\n });\n }\n };\n\n return t;\n});\n"],"names":["define","$","Y","FormChangeChecker","t","confirmDialogue","init","startButton","confirmationTitle","confirmationForm","popupoptions","finalStartButton","use","one","M","core","dialogue","headerContent","bodyContent","draggable","visible","center","modal","width","extraClasses","on","displayDialogue","hideDialogue","get","launchQuizPopup","e","halt","show","hide","form","target","ancestor","resetFormDirtyState","getDOMNode","window","openpopup","url","IO","stringify","replace","windowname","options","fullscreen"],"mappings":";;;;;;;;;;;AA0BAA,iCAAO,CAAC,SAAU,WAAY,4BAA4B,SAASC,EAAGC,EAAGC,uBAKjEC,EAAI,CACJC,gBAAiB,KAUjBC,KAAM,SAASC,YAAaC,kBAAmBC,iBAAkBC,kBACzDC,iBAAmBJ,YAEvBL,EAAEU,IAAI,4BAA4B,WAC1BV,EAAEW,IAAIJ,oBACNL,EAAEC,gBAAkB,IAAIS,EAAEC,KAAKC,SAAS,CACpCC,cAAeT,kBACfU,YAAahB,EAAEW,IAAIJ,kBACnBU,WAAW,EACXC,SAAS,EACTC,QAAQ,EACRC,OAAO,EACPC,MAAO,KACPC,aAAc,CAAC,8BAGnBtB,EAAEW,IAAIN,aAAakB,GAAG,QAASrB,EAAEsB,iBACjCxB,EAAEW,IAAI,cAAcY,GAAG,QAASrB,EAAEuB,cAElChB,iBAAmBP,EAAEC,gBAAgBuB,IAAI,eAAef,IAAI,0BAG5DH,cACAR,EAAEW,IAAIF,kBAAkBc,GAAG,QAASrB,EAAEyB,gBAAiBzB,EAAGM,kBAStEgB,gBAAiB,SAASI,GAClBA,GACAA,EAAEC,OAEN3B,EAAEC,gBAAgB2B,QAOtBL,aAAc,SAASG,GACfA,GACAA,EAAEC,OAEN3B,EAAEC,gBAAgB4B,KAAKH,IAQ3BD,gBAAiB,SAASC,EAAGpB,cACzBoB,EAAEC,OACF7B,EAAEU,IAAI,WAAW,eACTsB,KAAOJ,EAAEK,OAAOC,SAAS,QAE7BjC,kBAAkBkC,oBAAoBH,KAAKI,cAC3CC,OAAOC,UAAUV,EAAG,CAChBW,IAAKP,KAAKN,IAAI,UAAY,IAAM1B,EAAEwC,GAAGC,UAAUT,MAAMU,QAAQ,YAAa,MAC1EC,WAAY,YACZC,QAASpC,aACTqC,YAAY,gBAMrB3C"}PK9B\u944&amd/build/reopen_attempt_ui.min.js.mapnu[{"version":3,"file":"reopen_attempt_ui.min.js","sources":["../src/reopen_attempt_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 * This module has the code to make the Re-open attempt button work, if present.\n *\n * That is, it looks for buttons with HTML like\n * <button type=\"button\" data-action=\"reopen-attempt\" data-attempt-id=\"227000\" data-after-action-url=\"/mod/quiz/report.php\">\n * and if that is clicked, it first shows an 'Are you sure' pop-up, and if they are sure,\n * the attempt is re-opened, and then the page reloads.\n *\n * @module mod_quiz/reopen_attempt_ui\n * @copyright 2023 The Open University\n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport {exception as displayException} from 'core/notification';\nimport {call as fetchMany} from 'core/ajax';\nimport {getString} from 'core/str';\nimport {saveCancelPromise} from 'core/notification';\n\n/**\n * Handle a click if it is on one of our buttons.\n *\n * @param {MouseEvent} e the click event.\n */\nconst reopenButtonClicked = async(e) => {\n if (!(e.target instanceof HTMLElement) || !e.target.matches('button[data-action=\"reopen-attempt\"]')) {\n return;\n }\n\n e.preventDefault();\n const attemptId = e.target.dataset.attemptId;\n\n try {\n // We fetch the confirmation message from the server now, so the message is based\n // on the latest state of the attempt, rather than when the containing page loaded.\n const messages = fetchMany([{\n methodname: 'mod_quiz_get_reopen_attempt_confirmation',\n args: {\n \"attemptid\": attemptId,\n },\n }]);\n\n await saveCancelPromise(\n getString('reopenattemptareyousuretitle', 'mod_quiz'),\n messages[0],\n getString('reopenattempt', 'mod_quiz'),\n {triggerElement: e.target},\n );\n\n await (fetchMany([{\n methodname: 'mod_quiz_reopen_attempt',\n args: {\n \"attemptid\": attemptId,\n },\n }])[0]);\n window.location = M.cfg.wwwroot + e.target.dataset.afterActionUrl;\n\n } catch (error) {\n if (error.type === 'modal-save-cancel:cancel') {\n // User clicked Cancel, so do nothing.\n return;\n }\n await displayException(error);\n }\n};\n\nexport const init = () => {\n document.addEventListener('click', reopenButtonClicked);\n};\n"],"names":["reopenButtonClicked","async","e","target","HTMLElement","matches","preventDefault","attemptId","dataset","messages","methodname","args","triggerElement","window","location","M","cfg","wwwroot","afterActionUrl","error","type","document","addEventListener"],"mappings":";;;;;;;;;;;;;MAsCMA,oBAAsBC,MAAAA,SAClBC,EAAEC,kBAAkBC,aAAiBF,EAAEC,OAAOE,QAAQ,gDAI5DH,EAAEI,uBACIC,UAAYL,EAAEC,OAAOK,QAAQD,oBAKzBE,UAAW,cAAU,CAAC,CACxBC,WAAY,2CACZC,KAAM,WACWJ,oBAIf,oCACF,kBAAU,+BAAgC,YAC1CE,SAAS,IACT,kBAAU,gBAAiB,YAC3B,CAACG,eAAgBV,EAAEC,eAGhB,cAAU,CAAC,CACdO,WAAY,0BACZC,KAAM,WACWJ,cAEjB,GACJM,OAAOC,SAAWC,EAAEC,IAAIC,QAAUf,EAAEC,OAAOK,QAAQU,eAErD,MAAOC,UACc,6BAAfA,MAAMC,kBAIJ,2BAAiBD,uBAIX,KAChBE,SAASC,iBAAiB,QAAStB"}PK9B\tv3--1amd/build/random_question_form_preview.min.js.mapnu[{"version":3,"file":"random_question_form_preview.min.js","sources":["../src/random_question_form_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 the random_question_form_preview of the\n * add_random_form class.\n *\n * @module mod_quiz/random_question_form_preview\n * @copyright 2018 Ryan Wyllie \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\ndefine(\n [\n 'jquery',\n 'core/ajax',\n 'core/str',\n 'core/notification',\n 'core/templates',\n 'core/paged_content_factory'\n ],\n function(\n $,\n Ajax,\n Str,\n Notification,\n Templates,\n PagedContentFactory\n ) {\n\n var ITEMS_PER_PAGE = 5;\n var TEMPLATE_NAME = 'mod_quiz/random_question_form_preview_question_list';\n var SELECTORS = {\n LOADING_ICON_CONTAINER: '[data-region=\"overlay-icon-container\"]',\n QUESTION_COUNT_CONTAINER: '[data-region=\"question-count-container\"]',\n QUESTION_LIST_CONTAINER: '[data-region=\"question-list-container\"]'\n };\n\n /**\n * Show the loading spinner over the preview section.\n *\n * @param {jquery} root The root element.\n */\n var showLoadingIcon = function(root) {\n root.find(SELECTORS.LOADING_ICON_CONTAINER).removeClass('hidden');\n };\n\n /**\n * Hide the loading spinner.\n *\n * @param {jquery} root The root element.\n */\n var hideLoadingIcon = function(root) {\n root.find(SELECTORS.LOADING_ICON_CONTAINER).addClass('hidden');\n };\n\n /**\n * Render the section of text to show the question count.\n *\n * @param {jquery} root The root element.\n * @param {int} questionCount The number of questions.\n */\n var renderQuestionCount = function(root, questionCount) {\n Str.get_string('questionsmatchingfilter', 'mod_quiz', questionCount)\n .then(function(string) {\n root.find(SELECTORS.QUESTION_COUNT_CONTAINER).html(string);\n return;\n })\n .fail(Notification.exception);\n };\n\n /**\n * Send a request to the server for more questions.\n *\n * @param {int} categoryId A question category id.\n * @param {bool} includeSubcategories If the results should include subcategory questions\n * @param {int[]} tagIds The list of tag ids that each question must have.\n * @param {int} contextId The context where the questions will be added.\n * @param {int} limit How many questions to retrieve.\n * @param {int} offset How many questions to skip from the start of the result set.\n * @return {promise} Resolved when the preview section has rendered.\n */\n var requestQuestions = function(\n categoryId,\n includeSubcategories,\n tagIds,\n contextId,\n limit,\n offset\n ) {\n var request = {\n methodname: 'core_question_get_random_question_summaries',\n args: {\n categoryid: categoryId,\n includesubcategories: includeSubcategories,\n tagids: tagIds,\n contextid: contextId,\n limit: limit,\n offset: offset\n }\n };\n\n return Ajax.call([request])[0];\n };\n\n /**\n * Build a paged content widget for questions with the given criteria. The\n * criteria is used to fetch more questions from the server as the user\n * requests new pages.\n *\n * @param {int} categoryId A question category id.\n * @param {bool} includeSubcategories If the results should include subcategory questions\n * @param {int[]} tagIds The list of tag ids that each question must have.\n * @param {int} contextId The context where the questions will be added.\n * @param {int} totalQuestionCount How many questions match the criteria above.\n * @param {object[]} firstPageQuestions List of questions for the first page.\n * @return {promise} A promise resolved with the HTML and JS for the paged content.\n */\n var renderQuestionsAsPagedContent = function(\n categoryId,\n includeSubcategories,\n tagIds,\n contextId,\n totalQuestionCount,\n firstPageQuestions\n ) {\n // Provide a callback, renderQuestionsPages,\n // to control how the questions on each page are rendered.\n return PagedContentFactory.createFromAjax(\n totalQuestionCount,\n ITEMS_PER_PAGE,\n // Callback function to render the requested pages.\n function(pagesData) {\n return pagesData.map(function(pageData) {\n var limit = pageData.limit;\n var offset = pageData.offset;\n\n if (offset == 0) {\n // The first page is being requested and we've already got\n // that data so we can just render it immediately.\n return Templates.render(TEMPLATE_NAME, {questions: firstPageQuestions});\n } else {\n // Otherwise we need to ask the server for the data.\n return requestQuestions(\n categoryId,\n includeSubcategories,\n tagIds,\n contextId,\n limit,\n offset\n )\n .then(function(response) {\n var questions = response.questions;\n return Templates.render(TEMPLATE_NAME, {questions: questions});\n })\n .fail(Notification.exception);\n }\n });\n }\n );\n };\n\n /**\n * Re-render the preview section based on the provided filter criteria.\n *\n * @param {jquery} root The root element.\n * @param {int} categoryId A question category id.\n * @param {bool} includeSubcategories If the results should include subcategory questions\n * @param {int[]} tagIds The list of tag ids that each question must have.\n * @param {int} contextId The context where the questions will be added.\n * @return {promise} Resolved when the preview section has rendered.\n */\n var reload = function(root, categoryId, includeSubcategories, tagIds, contextId) {\n // Show the loading spinner to tell the user that something is happening.\n showLoadingIcon(root);\n // Load the first set of questions.\n return requestQuestions(categoryId, includeSubcategories, tagIds, contextId, ITEMS_PER_PAGE, 0)\n .then(function(response) {\n var totalCount = response.totalcount;\n // Show the help message for the user to indicate how many questions\n // match their filter criteria.\n renderQuestionCount(root, totalCount);\n return response;\n })\n .then(function(response) {\n var totalQuestionCount = response.totalcount;\n var questions = response.questions;\n\n if (questions.length) {\n // We received some questions so render them as paged content\n // with a paging bar.\n return renderQuestionsAsPagedContent(\n categoryId,\n includeSubcategories,\n tagIds,\n contextId,\n totalQuestionCount,\n questions\n );\n } else {\n // If we didn't receive any questions then we can return empty\n // HTML and JS to clear the preview section.\n return $.Deferred().resolve('', '');\n }\n })\n .then(function(html, js) {\n // Show the user the question set.\n var container = root.find(SELECTORS.QUESTION_LIST_CONTAINER);\n Templates.replaceNodeContents(container, html, js);\n return;\n })\n .always(function() {\n hideLoadingIcon(root);\n })\n .fail(Notification.exception);\n };\n\n return {\n reload: reload,\n showLoadingIcon: showLoadingIcon,\n hideLoadingIcon: hideLoadingIcon\n };\n});\n"],"names":["define","$","Ajax","Str","Notification","Templates","PagedContentFactory","TEMPLATE_NAME","SELECTORS","showLoadingIcon","root","find","removeClass","hideLoadingIcon","addClass","requestQuestions","categoryId","includeSubcategories","tagIds","contextId","limit","offset","request","methodname","args","categoryid","includesubcategories","tagids","contextid","call","reload","then","response","totalCount","totalcount","questionCount","get_string","string","html","fail","exception","renderQuestionCount","totalQuestionCount","questions","length","firstPageQuestions","createFromAjax","pagesData","map","pageData","render","renderQuestionsAsPagedContent","Deferred","resolve","js","container","replaceNodeContents","always"],"mappings":";;;;;;;;AAuBAA,+CACI,CACI,SACA,YACA,WACA,oBACA,iBACA,+BAEJ,SACIC,EACAC,KACAC,IACAC,aACAC,UACAC,yBAIAC,cAAgB,sDAChBC,iCACwB,yCADxBA,mCAE0B,2CAF1BA,kCAGyB,0CAQzBC,gBAAkB,SAASC,MAC3BA,KAAKC,KAAKH,kCAAkCI,YAAY,WAQxDC,gBAAkB,SAASH,MAC3BA,KAAKC,KAAKH,kCAAkCM,SAAS,WA6BrDC,iBAAmB,SACnBC,WACAC,qBACAC,OACAC,UACAC,MACAC,YAEIC,QAAU,CACVC,WAAY,8CACZC,KAAM,CACFC,WAAYT,WACZU,qBAAsBT,qBACtBU,OAAQT,OACRU,UAAWT,UACXC,MAAOA,MACPC,OAAQA,gBAITnB,KAAK2B,KAAK,CAACP,UAAU,UAmHzB,CACHQ,OA9CS,SAASpB,KAAMM,WAAYC,qBAAsBC,OAAQC,kBAElEV,gBAAgBC,MAETK,iBAAiBC,WAAYC,qBAAsBC,OAAQC,UAlJjD,EAkJ4E,GACxFY,MAAK,SAASC,cACPC,WAAaD,SAASE,kBApHZ,SAASxB,KAAMyB,eACrChC,IAAIiC,WAAW,0BAA2B,WAAYD,eACjDJ,MAAK,SAASM,QACX3B,KAAKC,KAAKH,oCAAoC8B,KAAKD,WAGtDE,KAAKnC,aAAaoC,WAiHfC,CAAoB/B,KAAMuB,YACnBD,YAEVD,MAAK,SAASC,cACPU,mBAAqBV,SAASE,WAC9BS,UAAYX,SAASW,iBAErBA,UAAUC,OAtEU,SAChC5B,WACAC,qBACAC,OACAC,UACAuB,mBACAG,2BAIOvC,oBAAoBwC,eACvBJ,mBAnGa,GAsGb,SAASK,kBACEA,UAAUC,KAAI,SAASC,cACtB7B,MAAQ6B,SAAS7B,MACjBC,OAAS4B,SAAS5B,cAER,GAAVA,OAGOhB,UAAU6C,OAAO3C,cAAe,CAACoC,UAAWE,qBAG5C9B,iBACHC,WACAC,qBACAC,OACAC,UACAC,MACAC,QAEHU,MAAK,SAASC,cACPW,UAAYX,SAASW,iBAClBtC,UAAU6C,OAAO3C,cAAe,CAACoC,UAAWA,eAEtDJ,KAAKnC,aAAaoC,iBAoChBW,CACHnC,WACAC,qBACAC,OACAC,UACAuB,mBACAC,WAKG1C,EAAEmD,WAAWC,QAAQ,GAAI,OAGvCtB,MAAK,SAASO,KAAMgB,QAEbC,UAAY7C,KAAKC,KAAKH,mCAC1BH,UAAUmD,oBAAoBD,UAAWjB,KAAMgB,OAGlDG,QAAO,WACJ5C,gBAAgBH,SAEnB6B,KAAKnC,aAAaoC,YAKvB/B,gBAAiBA,gBACjBI,gBAAiBA"}PK9B\aYH..)amd/build/modal_quiz_question_bank.min.jsnu[define("mod_quiz/modal_quiz_question_bank",["exports","./add_question_modal","core/fragment","core_form/changechecker","core/modal_events","core/notification"],(function(_exports,_add_question_modal,Fragment,FormChangeChecker,ModalEvents,Notification){var 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}Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.default=void 0,_add_question_modal=(obj=_add_question_modal)&&obj.__esModule?obj:{default:obj},Fragment=_interopRequireWildcard(Fragment),FormChangeChecker=_interopRequireWildcard(FormChangeChecker),ModalEvents=_interopRequireWildcard(ModalEvents),Notification=_interopRequireWildcard(Notification);const SELECTORS_ADD_TO_QUIZ_CONTAINER="td.addtoquizaction",SELECTORS_ANCHOR="a[href]",SELECTORS_PREVIEW_CONTAINER="td.previewquestionaction",SELECTORS_ADD_QUESTIONS_FORM="form#questionsubmit",SELECTORS_SORTERS=".sorters",SELECTORS_SWITCH_TO_OTHER_BANK='button[data-action="switch-question-bank"]',SELECTORS_NEW_BANKMOD_ID="data-newmodid",SELECTORS_BANK_SEARCH="#searchbanks",SELECTORS_GO_BACK_BUTTON='button[data-action="go-back"]',SELECTORS_ADD_ON_PAGE_FORM_ELEMENT='input[name="addonpage"]',SELECTORS_CMID_FORM_ELEMENT='form#questionsubmit input[name="cmid"]';class ModalQuizQuestionBank extends _add_question_modal.default{static init(contextId,bankCmId,quizCmId){document.addEventListener("click",(e=>{const trigger=e.target.closest('.menu [data-action="questionbank"]');trigger&&(e.preventDefault(),ModalQuizQuestionBank.create({contextId:contextId,quizCmId:quizCmId,bankCmId:bankCmId,title:trigger.dataset.header,addOnPage:trigger.dataset.addonpage,templateContext:{hidden:!0},large:!0}))}))}show(){return this.reloadBodyContent(window.location.search),super.show(this)}reloadBodyContent(querystring){this.hideFooter(),this.setTitle(this.originalTitle),this.setBody(Fragment.loadFragment("mod_quiz","quiz_question_bank",this.getContextId(),{querystring:querystring,quizcmid:this.quizCmId,bankcmid:this.bankCmId}))}handleAddToQuizEvent(e,anchorElement){const href=new URL(anchorElement.getAttribute("href"));href.searchParams.set("addonpage",this.getAddOnPageId()),href.searchParams.set("cmid",this.quizCmId),anchorElement.setAttribute("href",href)}registerEventListeners(){super.registerEventListeners(this),this.getModal().on("submit",SELECTORS_ADD_QUESTIONS_FORM,(e=>{const formElement=e.currentTarget;document.querySelector(SELECTORS_ADD_ON_PAGE_FORM_ELEMENT).setAttribute("value",this.getAddOnPageId()),document.querySelector(SELECTORS_CMID_FORM_ELEMENT).setAttribute("value",this.quizCmId);const actionUrl=new URL(formElement.getAttribute("action"));actionUrl.searchParams.set("cmid",this.quizCmId),formElement.setAttribute("action",actionUrl.toString())})),this.getModal().on("click",SELECTORS_SWITCH_TO_OTHER_BANK,(()=>{this.handleSwitchBankContentReload(SELECTORS_BANK_SEARCH).then((function(ModalQuizQuestionBank){var _document$querySelect;null===(_document$querySelect=document.querySelector(SELECTORS_BANK_SEARCH))||void 0===_document$querySelect||_document$querySelect.addEventListener("change",(e=>{const bankCmId=e.currentTarget.value;bankCmId>0&&(ModalQuizQuestionBank.bankCmId=bankCmId,ModalQuizQuestionBank.reloadBodyContent(window.location.search))})),document.querySelector(SELECTORS_GO_BACK_BUTTON).addEventListener("click",(e=>{ModalQuizQuestionBank.bankCmId=e.currentTarget.value,ModalQuizQuestionBank.reloadBodyContent(window.location.search)}))})).catch(Notification.exception)})),this.getModal().on("click",SELECTORS_ANCHOR,(e=>{const anchorElement=e.currentTarget;if(anchorElement.closest(SELECTORS_ADD_TO_QUIZ_CONTAINER))this.handleAddToQuizEvent(e,anchorElement);else if(!anchorElement.closest(SELECTORS_PREVIEW_CONTAINER)&&!anchorElement.closest(SELECTORS_SORTERS)){if(anchorElement.closest("a["+SELECTORS_NEW_BANKMOD_ID+"]")){this.bankCmId=anchorElement.getAttribute(SELECTORS_NEW_BANKMOD_ID);const url=new URL(location.href);url.searchParams.delete("filter"),history.pushState({},"",url)}e.preventDefault(),this.reloadBodyContent(anchorElement.search)}})),this.getRoot().on(ModalEvents.bodyRendered,(()=>{FormChangeChecker.disableAllChecks()}))}}return _exports.default=ModalQuizQuestionBank,function(obj,key,value){key in obj?Object.defineProperty(obj,key,{value:value,enumerable:!0,configurable:!0,writable:!0}):obj[key]=value}(ModalQuizQuestionBank,"TYPE","mod_quiz-quiz-question-bank"),ModalQuizQuestionBank.registerModalType(),_exports.default})); //# sourceMappingURL=modal_quiz_question_bank.min.js.mapPK9B\Y(8amd/build/update_random_question_filter_condition.min.jsnu[define("mod_quiz/update_random_question_filter_condition",["exports","core/ajax","core/notification"],(function(_exports,_ajax,_notification){function _interopRequireDefault(obj){return obj&&obj.__esModule?obj:{default:obj}} /** * Event handling for the edit random question form. * * Dynamically saves the new filter condition before navigating back to the quiz question list. * * @module mod_quiz/update_random_question_filter_condition * @author 2022 * @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=()=>{const SELECTORS_QUESTION_BANK_CONTAINER="#questionbank_container",SELECTORS_FORM_ELEMENT="#update_filter_condition_form",SELECTORS_UPDATE_BUTTON='[name="update"]',SELECTORS_MESSAGE_INPUT='[name="message"]',SELECTORS_FILTER_CONDITION_ELEMENT="[data-filtercondition]",questionBank=document.querySelector(SELECTORS_QUESTION_BANK_CONTAINER),form=document.querySelector(SELECTORS_FORM_ELEMENT);form.querySelector(SELECTORS_UPDATE_BUTTON).addEventListener("click",(async e=>{var _form$dataset,_form$dataset2,_questionBank$querySe;e.preventDefault();const request={methodname:"mod_quiz_update_filter_condition",args:{cmid:null===(_form$dataset=form.dataset)||void 0===_form$dataset?void 0:_form$dataset.cmid,slotid:null===(_form$dataset2=form.dataset)||void 0===_form$dataset2?void 0:_form$dataset2.slotid,filtercondition:null===(_questionBank$querySe=questionBank.querySelector(SELECTORS_FILTER_CONDITION_ELEMENT).dataset)||void 0===_questionBank$querySe?void 0:_questionBank$querySe.filtercondition}};try{const response=await _ajax.default.call([request])[0];form.querySelector(SELECTORS_MESSAGE_INPUT).value=response.message,form.submit()}catch(e){_notification.default.exception(e)}}))}})); //# sourceMappingURL=update_random_question_filter_condition.min.js.mapPK9B\9ٔamd/build/question_slot.min.jsnu[define("mod_quiz/question_slot",["exports","core/ajax","core/notification"],(function(_exports,_ajax,_notification){var obj; /** * Render the question slot template for each question in the quiz edit view. * * @module mod_quiz/question_slot * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Guillermo Gomez Arias * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.init=void 0,_notification=(obj=_notification)&&obj.__esModule?obj:{default:obj};const registerEventListeners=()=>{document.addEventListener("change",(e=>{if(!e.target.matches('[data-action="mod_quiz-select_slot"][data-slot-id]'))return;const slotId=e.target.dataset.slotId,newVersion=parseInt(e.target.value);((slotId,newVersion)=>(0,_ajax.call)([{methodname:"mod_quiz_set_question_version",args:{slotid:slotId,newversion:newVersion}}])[0])(slotId,0===newVersion?null:newVersion).then((()=>{location.reload()})).catch(_notification.default.exception)}))};_exports.init=()=>{registerEventListeners()}})); //# sourceMappingURL=question_slot.min.js.mapPK9B\Q##%amd/build/edit_multiple_grades.min.jsnu[define("mod_quiz/edit_multiple_grades",["exports","core/ajax","core/config","core/loadingicon","core/notification","core/pending","core/str","core/templates"],(function(_exports,_ajax,_config,_loadingicon,_notification,_pending,_str,_templates){function _interopRequireDefault(obj){return obj&&obj.__esModule?obj:{default:obj}} /** * JavaScript for managing multiple grade items for a quiz. * * @module mod_quiz/edit_multiple_grades * @copyright 2023 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.init=void 0,_config=_interopRequireDefault(_config),_notification=_interopRequireDefault(_notification),_pending=_interopRequireDefault(_pending);const SELECTORS_addGradeItemButton="#mod_quiz-add_grade_item",SELECTORS_autoSetupButton="#mod_quiz-grades_auto_setup",SELECTORS_editingPageContents="#edit_grading_page-contents",SELECTORS_gradeItemList="table#mod_quiz-grade-item-list",SELECTORS_gradeItemSelect="select[data-slot-id]",SELECTORS_gradeItemSelectId=id=>"select#grade-item-choice-"+id,SELECTORS_gradeItemTr="table#mod_quiz-grade-item-list tr[data-quiz-grade-item-id]",SELECTORS_inplaceEditable="span.inplaceeditable",SELECTORS_inplaceEditableOn="span.inplaceeditable.inplaceeditingon",SELECTORS_resetAllButton="#mod_quiz-grades_reset_all",SELECTORS_slotList="table#mod_quiz-slot-list",SELECTORS_updateGradeItemLink=id=>'tr[data-quiz-grade-item-id="'+id+'"] .quickeditlink',callServiceAndReturnRenderingData=methodCall=>callServicesAndReturnRenderingData([methodCall]),callServicesAndReturnRenderingData=methodCalls=>(methodCalls.push({methodname:"mod_quiz_get_edit_grading_page_data",args:{quizid:methodCalls[0].args.quizid}}),Promise.all((0,_ajax.call)(methodCalls)).then((results=>JSON.parse(results.at(-1))))),handleGradeItemDelete=e=>{e.preventDefault();const pending=new _pending.default("delete-quiz-grade-item"),tableCell=e.target.closest("td");(0,_loadingicon.addIconToContainer)(tableCell,pending);const tableRow=tableCell.closest("tr"),quizId=tableRow.closest("table").dataset.quizId,gradeItemId=tableRow.dataset.quizGradeItemId;let nextItemToFocus;nextItemToFocus=tableRow.nextElementSibling?SELECTORS_updateGradeItemLink(tableRow.nextElementSibling.dataset.quizGradeItemId):SELECTORS_addGradeItemButton,((quizId,gradeItemId)=>callServiceAndReturnRenderingData({methodname:"mod_quiz_delete_grade_items",args:{quizid:quizId,quizgradeitems:[{id:gradeItemId}]}}))(quizId,gradeItemId).then(reRenderPage).then((()=>{pending.resolve(),document.querySelector(nextItemToFocus).focus()})).catch(_notification.default.exception)},stopEditingGadeItem=editableSpan=>{editableSpan.innerHTML=editableSpan.dataset.oldContent,delete editableSpan.dataset.oldContent,editableSpan.classList.remove("inplaceeditingon"),editableSpan.querySelector("[data-action-edit]").focus()},handleGradeItemKeyDown=e=>{if(13!==e.keyCode)return;const editableSpan=e.target.closest(SELECTORS_inplaceEditableOn);if(!editableSpan||!editableSpan.closest(SELECTORS_gradeItemList))return;e.preventDefault();const pending=new _pending.default("edit-quiz-grade-item-save"),newName=editableSpan.querySelector("input").value,tableCell=e.target.closest("th");(0,_loadingicon.addIconToContainer)(tableCell);const tableRow=tableCell.closest("tr"),quizId=tableRow.closest("table").dataset.quizId,gradeItemId=tableRow.dataset.quizGradeItemId;((quizId,gradeItemId,newName)=>callServiceAndReturnRenderingData({methodname:"mod_quiz_update_grade_items",args:{quizid:quizId,quizgradeitems:[{id:gradeItemId,name:newName}]}}))(quizId,gradeItemId,newName).then(reRenderPage).then((()=>{pending.resolve(),document.querySelector(SELECTORS_updateGradeItemLink(gradeItemId)).focus({focusVisible:!0})})).catch(_notification.default.exception)},reRenderPage=editGradingPageData=>(0,_templates.render)("mod_quiz/edit_grading_page",editGradingPageData).then(((html,js)=>(0,_templates.replaceNode)(document.querySelector(SELECTORS_editingPageContents),html,js||""))),handleGradeItemKeyUp=e=>{if(27!==e.keyCode)return;const editableSpan=e.target.closest(SELECTORS_inplaceEditableOn);editableSpan&&editableSpan.closest(SELECTORS_gradeItemList)&&(e.preventDefault(),stopEditingGadeItem(editableSpan))},handleGradeItemFocusOut=e=>{if(_config.default.behatsiterunning)return;const editableSpan=e.target.closest(SELECTORS_inplaceEditableOn);editableSpan&&editableSpan.closest(SELECTORS_gradeItemList)&&(e.preventDefault(),stopEditingGadeItem(editableSpan))},handleSlotGradeItemChanged=e=>{const select=e.target.closest(SELECTORS_gradeItemSelect);if(!select||!select.closest(SELECTORS_slotList))return;e.preventDefault();const pending=new _pending.default("edit-slot-grade-item-updated"),slotId=select.dataset.slotId,newGradeItemId=select.value?select.value:null,tableCell=e.target.closest("td");(0,_loadingicon.addIconToContainer)(tableCell,pending);((quizId,slotId,gradeItemId)=>callServiceAndReturnRenderingData({methodname:"mod_quiz_update_slots",args:{quizid:quizId,slots:[{id:slotId,quizgradeitemid:gradeItemId}]}}))(tableCell.closest("table").dataset.quizId,slotId,newGradeItemId).then(reRenderPage).then((()=>{pending.resolve(),document.querySelector(SELECTORS_gradeItemSelectId(slotId)).focus()})).catch(_notification.default.exception)},handleGradeItemClick=e=>{const link=e.target.closest("a");link&&link.closest(SELECTORS_gradeItemList)&&(link.dataset.actionDelete&&handleGradeItemDelete(e),link.dataset.actionEdit&&(e=>{e.preventDefault();const pending=new _pending.default("edit-quiz-grade-item-start"),editableSpan=e.target.closest(SELECTORS_inplaceEditable);document.querySelectorAll(SELECTORS_inplaceEditableOn).forEach(stopEditingGadeItem),editableSpan.dataset.oldContent=editableSpan.innerHTML,(0,_str.get_string)("edittitleinstructions").then((instructions=>{const uniqueId="gi-edit-input-"+editableSpan.closest("tr").dataset.quizGradeItemId;editableSpan.innerHTML=''+instructions+'';const inputElement=editableSpan.querySelector("input");return inputElement.focus(),inputElement.select(),editableSpan.classList.add("inplaceeditingon"),pending.resolve(),null})).catch(_notification.default.exception)})(e))},handleButtonClick=e=>{e.target.closest(SELECTORS_addGradeItemButton)&&handleAddGradeItemClick(e),e.target.closest(SELECTORS_autoSetupButton)&&handleAutoSetup(e),e.target.closest(SELECTORS_resetAllButton)&&handleResetAllClick(e)},handleAddGradeItemClick=e=>{e.preventDefault();const pending=new _pending.default("create-quiz-grade-item");(0,_loadingicon.addIconToContainer)(e.target.parentNode,pending);(quizId=>callServiceAndReturnRenderingData({methodname:"mod_quiz_create_grade_items",args:{quizid:quizId,quizgradeitems:[{name:""}]}}))(e.target.dataset.quizId).then(reRenderPage).then((()=>{pending.resolve(),document.querySelector(SELECTORS_addGradeItemButton).focus()})).catch(_notification.default.exception)},handleAutoSetup=e=>{e.preventDefault();const pending=new _pending.default("setup-quiz-grade-items");(quizId=>callServiceAndReturnRenderingData({methodname:"mod_quiz_create_grade_item_per_section",args:{quizid:quizId}}))(e.target.dataset.quizId).then(reRenderPage).then((()=>{pending.resolve(),document.querySelector(SELECTORS_resetAllButton).focus()})).catch(_notification.default.exception)},handleResetAllClick=e=>{e.preventDefault();const button=e.target;_notification.default.deleteCancelPromise((0,_str.get_string)("gradeitemsremoveallconfirm","quiz"),(0,_str.get_string)("gradeitemsremoveallmessage","quiz"),(0,_str.get_string)("reset"),button).then((()=>reallyResetAll(button))).catch((()=>button.focus()))},reallyResetAll=button=>{const pending=new _pending.default("reset-quiz-grading");(0,_loadingicon.addIconToContainer)(button.parentNode,pending);const quizId=button.dataset.quizId;let methodCalls=[];const slotResets=[...document.querySelectorAll(SELECTORS_gradeItemSelect)].map((select=>({id:select.dataset.slotId,quizgradeitemid:0})));slotResets.length&&methodCalls.push({methodname:"mod_quiz_update_slots",args:{quizid:quizId,slots:slotResets}}),methodCalls.push({methodname:"mod_quiz_delete_grade_items",args:{quizid:quizId,quizgradeitems:[...document.querySelectorAll(SELECTORS_gradeItemTr)].map((tr=>({id:tr.dataset.quizGradeItemId})))}}),callServicesAndReturnRenderingData(methodCalls).then(reRenderPage).then((()=>{pending.resolve(),document.querySelector(SELECTORS_addGradeItemButton).focus()})).catch(_notification.default.exception)};_exports.init=()=>{document.body.addEventListener("click",handleGradeItemClick),document.body.addEventListener("keydown",handleGradeItemKeyDown),document.body.addEventListener("keyup",handleGradeItemKeyUp),document.body.addEventListener("focusout",handleGradeItemFocusOut),document.body.addEventListener("click",handleButtonClick),document.body.addEventListener("change",handleSlotGradeItemChanged)}})); //# sourceMappingURL=edit_multiple_grades.min.js.mapPK9B\$k` amd/build/add_random_form.min.jsnu[/** * JavaScript for the add_random_form class. * * @module mod_quiz/add_random_form * @copyright 2018 Ryan Wyllie * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ define("mod_quiz/add_random_form",["jquery","mod_quiz/random_question_form_preview"],(function($,RandomQuestionFormPreview){var SELECTORS_PREVIEW_CONTAINER='[data-region="random-question-preview-container"]',SELECTORS_CATEGORY_FORM_ELEMENT='[name="category"]',SELECTORS_SUBCATEGORY_FORM_ELEMENT='[name="includesubcategories"]',SELECTORS_TAG_IDS_FORM_ELEMENT='[name="fromtags[]"]',getCategorySelectValue=function(form){return form.find(SELECTORS_CATEGORY_FORM_ELEMENT).val()},shouldIncludeSubcategories=function(form,topCategories){return!!function(form,topCategories){var selectedValue=getCategorySelectValue(form);return topCategories.indexOf(selectedValue)>-1}(form,topCategories)||form.find(SELECTORS_SUBCATEGORY_FORM_ELEMENT).is(":checked")},reloadQuestionPreview=function(form,contextId,topCategories){var previewContainer=form.find(SELECTORS_PREVIEW_CONTAINER);RandomQuestionFormPreview.reload(previewContainer,function(form){return getCategorySelectValue(form).split(",")[0]}(form),shouldIncludeSubcategories(form,topCategories),function(form){return form.find(SELECTORS_TAG_IDS_FORM_ELEMENT).val().map((function(value){return value.split(",")[0]}))}(form),contextId)},addEventListeners=function(form,contextId,topCategories){var reloadTimerId=null;const tagsFilter=form.find(SELECTORS_TAG_IDS_FORM_ELEMENT);form.add(tagsFilter).on("change",(function(e){var element;((element=$(e.target)).closest(SELECTORS_CATEGORY_FORM_ELEMENT).length>0||element.closest(SELECTORS_SUBCATEGORY_FORM_ELEMENT).length>0||element.closest(SELECTORS_TAG_IDS_FORM_ELEMENT).length>0)&&(RandomQuestionFormPreview.showLoadingIcon(form),reloadTimerId&&clearTimeout(reloadTimerId),reloadTimerId=setTimeout((function(){reloadQuestionPreview(form,contextId,topCategories)}),2e3))}))};return{init:function(formId,contextId,topCategories,isTagsEnabled){if(1==isTagsEnabled){var form=$("#"+formId);reloadQuestionPreview(form,contextId,topCategories),addEventListeners(form,contextId,topCategories)}}}})); //# sourceMappingURL=add_random_form.min.js.mapPK9B\~ ~ <amd/build/update_random_question_filter_condition.min.js.mapnu[{"version":3,"file":"update_random_question_filter_condition.min.js","sources":["../src/update_random_question_filter_condition.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 * Event handling for the edit random question form.\n *\n * Dynamically saves the new filter condition before navigating back to the quiz question list.\n *\n * @module mod_quiz/update_random_question_filter_condition\n * @author 2022 \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\nexport const init = () => {\n const SELECTORS = {\n QUESTION_BANK_CONTAINER: '#questionbank_container',\n FORM_ELEMENT: '#update_filter_condition_form',\n UPDATE_BUTTON: '[name=\"update\"]',\n CANCEL_BUTTON: '[name=\"cancel\"]',\n MESSAGE_INPUT: '[name=\"message\"]',\n FILTER_CONDITION_ELEMENT: '[data-filtercondition]',\n };\n\n const questionBank = document.querySelector(SELECTORS.QUESTION_BANK_CONTAINER);\n const form = document.querySelector(SELECTORS.FORM_ELEMENT);\n const updateButton = form.querySelector(SELECTORS.UPDATE_BUTTON);\n\n updateButton.addEventListener(\"click\", async(e) => {\n e.preventDefault();\n const request = {\n methodname: 'mod_quiz_update_filter_condition',\n args: {\n cmid: form.dataset?.cmid,\n slotid: form.dataset?.slotid,\n filtercondition: questionBank.querySelector(SELECTORS.FILTER_CONDITION_ELEMENT).dataset?.filtercondition,\n }\n };\n try {\n const response = await ajax.call([request])[0];\n const messageInput = form.querySelector(SELECTORS.MESSAGE_INPUT);\n messageInput.value = response.message;\n form.submit();\n } catch (e) {\n Notification.exception(e);\n }\n });\n\n};\n"],"names":["SELECTORS","questionBank","document","querySelector","form","addEventListener","async","e","preventDefault","request","methodname","args","cmid","dataset","_form$dataset","slotid","_form$dataset2","filtercondition","_questionBank$querySe","response","ajax","call","value","message","submit","exception"],"mappings":";;;;;;;;;wLA4BoB,WACVA,kCACuB,0BADvBA,uBAEY,gCAFZA,wBAGa,kBAHbA,wBAKa,mBALbA,mCAMwB,yBAGxBC,aAAeC,SAASC,cAAcH,mCACtCI,KAAOF,SAASC,cAAcH,wBACfI,KAAKD,cAAcH,yBAE3BK,iBAAiB,SAASC,MAAAA,2DACnCC,EAAEC,uBACIC,QAAU,CACZC,WAAY,mCACZC,KAAM,CACFC,2BAAMR,KAAKS,wCAALC,cAAcF,KACpBG,8BAAQX,KAAKS,yCAALG,eAAcD,OACtBE,8CAAiBhB,aAAaE,cAAcH,oCAAoCa,gDAA/DK,sBAAwED,4BAIvFE,eAAiBC,cAAKC,KAAK,CAACZ,UAAU,GACvBL,KAAKD,cAAcH,yBAC3BsB,MAAQH,SAASI,QAC9BnB,KAAKoB,SACP,MAAOjB,yBACQkB,UAAUlB"}PK9B\ZZ)amd/build/edit_multiple_grades.min.js.mapnu[{"version":3,"file":"edit_multiple_grades.min.js","sources":["../src/edit_multiple_grades.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 managing multiple grade items for a quiz.\n *\n * @module mod_quiz/edit_multiple_grades\n * @copyright 2023 The Open University\n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport {call as fetchMany} from 'core/ajax';\nimport MoodleConfig from 'core/config';\nimport {addIconToContainer} from 'core/loadingicon';\nimport Notification from 'core/notification';\nimport Pending from 'core/pending';\nimport {get_string as getString} from 'core/str';\nimport {render as renderTemplate} from 'core/templates';\nimport {replaceNode} from 'core/templates';\n\n/**\n * @type {Object} selectors used in this code.\n */\nconst SELECTORS = {\n 'addGradeItemButton': '#mod_quiz-add_grade_item',\n 'autoSetupButton': '#mod_quiz-grades_auto_setup',\n 'editingPageContents': '#edit_grading_page-contents',\n 'gradeItemList': 'table#mod_quiz-grade-item-list',\n 'gradeItemSelect': 'select[data-slot-id]',\n 'gradeItemSelectId': (id) => 'select#grade-item-choice-' + id,\n 'gradeItemTr': 'table#mod_quiz-grade-item-list tr[data-quiz-grade-item-id]',\n 'inplaceEditable': 'span.inplaceeditable',\n 'inplaceEditableOn': 'span.inplaceeditable.inplaceeditingon',\n 'resetAllButton': '#mod_quiz-grades_reset_all',\n 'slotList': 'table#mod_quiz-slot-list',\n 'updateGradeItemLink': (id) => 'tr[data-quiz-grade-item-id=\"' + id + '\"] .quickeditlink',\n};\n\n/**\n * Call the Ajax service to create a quiz grade item.\n *\n * @param {Number} quizId id of the quiz to update.\n * @returns {Promise} a promise that resolves to the template context required to re-render the page.\n */\nconst createGradeItem = (\n quizId,\n) => callServiceAndReturnRenderingData({\n methodname: 'mod_quiz_create_grade_items',\n args: {\n quizid: quizId,\n quizgradeitems: [{name: ''}],\n }\n});\n\n/**\n * Call the Ajax service to update a quiz grade item.\n *\n * @param {Number} quizId id of the quiz to update.\n * @param {Number} gradeItemId id of the grade item to update.\n * @param {String} newName the new name to set.\n * @return {Promise} Promise that resolves to the context required to re-render the page.\n */\nconst updateGradeItem = (\n quizId,\n gradeItemId,\n newName\n) => callServiceAndReturnRenderingData({\n methodname: 'mod_quiz_update_grade_items',\n args: {\n quizid: quizId,\n quizgradeitems: [{id: gradeItemId, name: newName}],\n }\n});\n\n/**\n * Call the Ajax service to delete a quiz grade item.\n *\n * @param {Number} quizId id of the quiz to update.\n * @param {Number} gradeItemId id of the grade item to delete.\n * @return {Promise} Promise that resolves to the context required to re-render the page.\n */\nconst deleteGradeItem = (\n quizId,\n gradeItemId\n) => callServiceAndReturnRenderingData({\n methodname: 'mod_quiz_delete_grade_items',\n args: {\n quizid: quizId,\n quizgradeitems: [{id: gradeItemId}],\n }\n});\n\n/**\n * Call the Ajax service to update the quiz grade item used by a slot.\n *\n * @param {Number} quizId id of the quiz to update.\n * @param {Number} slotId id of the slot to update.\n * @param {Number|null} gradeItemId new grade item ot set, or null to un-set.\n * @return {Promise} Promise that resolves to the context required to re-render the page.\n */\nconst updateSlotGradeItem = (\n quizId,\n slotId,\n gradeItemId\n) => callServiceAndReturnRenderingData({\n methodname: 'mod_quiz_update_slots',\n args: {\n quizid: quizId,\n slots: [{id: slotId, quizgradeitemid: gradeItemId}],\n }\n});\n\n/**\n * Call the Ajax service to setup one grade item for each quiz section.\n *\n * @param {Number} quizId id of the quiz to update.\n * @return {Promise} Promise that resolves to the context required to re-render the page.\n */\nconst autoSetupGradeItems = (\n quizId\n) => callServiceAndReturnRenderingData({\n methodname: 'mod_quiz_create_grade_item_per_section',\n args: {\n quizid: quizId\n }\n});\n\n/**\n * Make a web service call, and also call mod_quiz_get_edit_grading_page_data to get the date to re-render the page.\n *\n * @param {Object} methodCall a web service call to pass to fetchMany. Must include methodCall.args.quizid.\n * @returns {Promise} a promise that resolves to the template context required to re-render the page.\n */\nconst callServiceAndReturnRenderingData = (methodCall) => callServicesAndReturnRenderingData([methodCall]);\n\n/**\n * Make a web service call, and also call mod_quiz_get_edit_grading_page_data to get the date to re-render the page.\n *\n * @param {Object[]} methodCalls web service calls to pass to fetchMany. Must include methodCalls[0].args.quizid.\n * @returns {Promise} a promise that resolves to the template context required to re-render the page.\n */\nconst callServicesAndReturnRenderingData = (methodCalls) => {\n methodCalls.push({\n methodname: 'mod_quiz_get_edit_grading_page_data',\n args: {\n quizid: methodCalls[0].args.quizid,\n }\n });\n return Promise.all(fetchMany(methodCalls))\n .then(results => JSON.parse(results.at(-1)));\n};\n\n/**\n * Handle click events on the delete icon.\n *\n * @param {Event} e click event.\n */\nconst handleGradeItemDelete = (e) => {\n e.preventDefault();\n const pending = new Pending('delete-quiz-grade-item');\n\n const tableCell = e.target.closest('td');\n addIconToContainer(tableCell, pending);\n\n const tableRow = tableCell.closest('tr');\n const quizId = tableRow.closest('table').dataset.quizId;\n const gradeItemId = tableRow.dataset.quizGradeItemId;\n\n let nextItemToFocus;\n if (tableRow.nextElementSibling) {\n nextItemToFocus = SELECTORS.updateGradeItemLink(tableRow.nextElementSibling.dataset.quizGradeItemId);\n } else {\n nextItemToFocus = SELECTORS.addGradeItemButton;\n }\n\n deleteGradeItem(quizId, gradeItemId)\n .then(reRenderPage)\n .then(() => {\n pending.resolve();\n document.querySelector(nextItemToFocus).focus();\n })\n .catch(Notification.exception);\n};\n\n/**\n *\n * @param {HTMLElement} editableSpan the editable to turn off.\n */\nconst stopEditingGadeItem = (editableSpan) => {\n editableSpan.innerHTML = editableSpan.dataset.oldContent;\n delete editableSpan.dataset.oldContent;\n\n editableSpan.classList.remove('inplaceeditingon');\n editableSpan.querySelector('[data-action-edit]').focus();\n};\n\n/**\n * Handle click events on the start rename icon.\n *\n * @param {Event} e click event.\n */\nconst handleGradeItemEditStart = (e) => {\n e.preventDefault();\n const pending = new Pending('edit-quiz-grade-item-start');\n const editableSpan = e.target.closest(SELECTORS.inplaceEditable);\n\n document.querySelectorAll(SELECTORS.inplaceEditableOn).forEach(stopEditingGadeItem);\n\n editableSpan.dataset.oldContent = editableSpan.innerHTML;\n getString('edittitleinstructions')\n .then((instructions) => {\n const uniqueId = 'gi-edit-input-' + editableSpan.closest('tr').dataset.quizGradeItemId;\n editableSpan.innerHTML = '' + instructions + '' +\n '' +\n '';\n\n const inputElement = editableSpan.querySelector('input');\n inputElement.focus();\n inputElement.select();\n editableSpan.classList.add('inplaceeditingon');\n pending.resolve();\n return null;\n })\n .catch(Notification.exception);\n};\n\n/**\n * Handle key down in the editable.\n *\n * @param {Event} e key event.\n */\nconst handleGradeItemKeyDown = (e) => {\n if (e.keyCode !== 13) {\n return;\n }\n\n const editableSpan = e.target.closest(SELECTORS.inplaceEditableOn);\n\n // Check this click is on a relevant element.\n if (!editableSpan || !editableSpan.closest(SELECTORS.gradeItemList)) {\n return;\n }\n\n e.preventDefault();\n const pending = new Pending('edit-quiz-grade-item-save');\n\n const newName = editableSpan.querySelector('input').value;\n const tableCell = e.target.closest('th');\n addIconToContainer(tableCell);\n\n const tableRow = tableCell.closest('tr');\n const quizId = tableRow.closest('table').dataset.quizId;\n const gradeItemId = tableRow.dataset.quizGradeItemId;\n\n updateGradeItem(quizId, gradeItemId, newName)\n .then(reRenderPage)\n .then(() => {\n pending.resolve();\n document.querySelector(SELECTORS.updateGradeItemLink(gradeItemId)).focus({'focusVisible': true});\n })\n .catch(Notification.exception);\n};\n\n/**\n * Replace the contents of the page with the page re-rendered from the provided data, once that promise resolves.\n *\n * @param {Object} editGradingPageData the template context data required to re-render the page.\n * @returns {Promise} a promise that will resolve when the page is updated.\n */\nconst reRenderPage = (editGradingPageData) =>\n renderTemplate('mod_quiz/edit_grading_page', editGradingPageData)\n .then((html, js) => replaceNode(document.querySelector(SELECTORS.editingPageContents), html, js || ''));\n\n/**\n * Handle key up in the editable.\n *\n * @param {Event} e key event.\n */\nconst handleGradeItemKeyUp = (e) => {\n if (e.keyCode !== 27) {\n return;\n }\n\n const editableSpan = e.target.closest(SELECTORS.inplaceEditableOn);\n\n // Check this click is on a relevant element.\n if (!editableSpan || !editableSpan.closest(SELECTORS.gradeItemList)) {\n return;\n }\n\n e.preventDefault();\n stopEditingGadeItem(editableSpan);\n};\n\n/**\n * Handle focus out of the editable.\n *\n * @param {Event} e event.\n */\nconst handleGradeItemFocusOut = (e) => {\n if (MoodleConfig.behatsiterunning) {\n // Behat triggers focusout too often so ignore.\n return;\n }\n\n const editableSpan = e.target.closest(SELECTORS.inplaceEditableOn);\n\n // Check this click is on a relevant element.\n if (!editableSpan || !editableSpan.closest(SELECTORS.gradeItemList)) {\n return;\n }\n\n e.preventDefault();\n stopEditingGadeItem(editableSpan);\n};\n\n/**\n * Handle when the selected grade item for a slot is changed.\n *\n * @param {Event} e event.\n */\nconst handleSlotGradeItemChanged = (e) => {\n const select = e.target.closest(SELECTORS.gradeItemSelect);\n\n // Check this click is on a relevant element.\n if (!select || !select.closest(SELECTORS.slotList)) {\n return;\n }\n\n e.preventDefault();\n const pending = new Pending('edit-slot-grade-item-updated');\n\n const slotId = select.dataset.slotId;\n const newGradeItemId = select.value ? select.value : null;\n const tableCell = e.target.closest('td');\n addIconToContainer(tableCell, pending);\n\n const quizId = tableCell.closest('table').dataset.quizId;\n\n updateSlotGradeItem(quizId, slotId, newGradeItemId)\n .then(reRenderPage)\n .then(() => {\n pending.resolve();\n document.querySelector(SELECTORS.gradeItemSelectId(slotId)).focus();\n })\n .catch(Notification.exception);\n};\n\n/**\n * Handle clicks in the table the shows the grade items.\n *\n * @param {Event} e click event.\n */\nconst handleGradeItemClick = (e) => {\n const link = e.target.closest('a');\n\n // Check this click is on a relevant element.\n if (!link || !link.closest(SELECTORS.gradeItemList)) {\n return;\n }\n\n if (link.dataset.actionDelete) {\n handleGradeItemDelete(e);\n }\n\n if (link.dataset.actionEdit) {\n handleGradeItemEditStart(e);\n }\n};\n\n/**\n * Handle clicks on the buttons.\n *\n * @param {Event} e click event.\n */\n\nconst handleButtonClick = (e) => {\n if (e.target.closest(SELECTORS.addGradeItemButton)) {\n handleAddGradeItemClick(e);\n }\n if (e.target.closest(SELECTORS.autoSetupButton)) {\n handleAutoSetup(e);\n }\n if (e.target.closest(SELECTORS.resetAllButton)) {\n handleResetAllClick(e);\n }\n};\n\n/**\n * Handle clicks on the 'Add grade item' button.\n *\n * @param {Event} e click event.\n */\nconst handleAddGradeItemClick = (e) => {\n e.preventDefault();\n const pending = new Pending('create-quiz-grade-item');\n addIconToContainer(e.target.parentNode, pending);\n\n const quizId = e.target.dataset.quizId;\n\n createGradeItem(quizId)\n .then(reRenderPage)\n .then(() => {\n pending.resolve();\n document.querySelector(SELECTORS.addGradeItemButton).focus();\n })\n .catch(Notification.exception);\n};\n\n/**\n * Handle clicks on the reset button - show a confirmation.\n *\n * @param {Event} e click event.\n */\nconst handleAutoSetup = (e) => {\n e.preventDefault();\n const pending = new Pending('setup-quiz-grade-items');\n\n const quizId = e.target.dataset.quizId;\n\n autoSetupGradeItems(quizId)\n .then(reRenderPage)\n .then(() => {\n pending.resolve();\n document.querySelector(SELECTORS.resetAllButton).focus();\n })\n .catch(Notification.exception);\n};\n\n/**\n * Handle clicks on the reset button - show a confirmation.\n *\n * @param {Event} e click event.\n */\nconst handleResetAllClick = (e) => {\n e.preventDefault();\n const button = e.target;\n\n Notification.deleteCancelPromise(\n getString('gradeitemsremoveallconfirm', 'quiz'),\n getString('gradeitemsremoveallmessage', 'quiz'),\n getString('reset'),\n button\n ).then(() => reallyResetAll(button))\n .catch(() => button.focus());\n};\n\n/**\n * Really reset all if the confirmation is OKed.\n *\n * @param {HTMLElement} button the reset button.\n */\nconst reallyResetAll = (button) => {\n const pending = new Pending('reset-quiz-grading');\n addIconToContainer(button.parentNode, pending);\n\n const quizId = button.dataset.quizId;\n\n let methodCalls = [];\n\n // Call to clear any assignments of grade items to slots (if required).\n const slotResets = [...document.querySelectorAll(SELECTORS.gradeItemSelect)].map(\n (select) => ({\n id: select.dataset.slotId,\n quizgradeitemid: 0,\n }));\n if (slotResets.length) {\n methodCalls.push({\n methodname: 'mod_quiz_update_slots',\n args: {\n quizid: quizId,\n slots: slotResets\n }\n });\n }\n\n // Request to delete all the grade items.\n methodCalls.push({\n methodname: 'mod_quiz_delete_grade_items',\n args: {\n quizid: quizId,\n quizgradeitems: [...document.querySelectorAll(SELECTORS.gradeItemTr)].map((tr) => {\n return {id: tr.dataset.quizGradeItemId};\n })\n }\n });\n\n callServicesAndReturnRenderingData(methodCalls)\n .then(reRenderPage)\n .then(() => {\n pending.resolve();\n document.querySelector(SELECTORS.addGradeItemButton).focus();\n })\n .catch(Notification.exception);\n};\n\n/**\n * Replace the container with a new version.\n */\nconst registerEventListeners = () => {\n document.body.addEventListener('click', handleGradeItemClick);\n document.body.addEventListener('keydown', handleGradeItemKeyDown);\n document.body.addEventListener('keyup', handleGradeItemKeyUp);\n document.body.addEventListener('focusout', handleGradeItemFocusOut);\n\n document.body.addEventListener('click', handleButtonClick);\n\n document.body.addEventListener('change', handleSlotGradeItemChanged);\n};\n\n/**\n * Entry point.\n */\nexport const init = () => {\n registerEventListeners();\n};\n"],"names":["SELECTORS","id","callServiceAndReturnRenderingData","methodCall","callServicesAndReturnRenderingData","methodCalls","push","methodname","args","quizid","Promise","all","then","results","JSON","parse","at","handleGradeItemDelete","e","preventDefault","pending","Pending","tableCell","target","closest","tableRow","quizId","dataset","gradeItemId","quizGradeItemId","nextItemToFocus","nextElementSibling","quizgradeitems","deleteGradeItem","reRenderPage","resolve","document","querySelector","focus","catch","Notification","exception","stopEditingGadeItem","editableSpan","innerHTML","oldContent","classList","remove","handleGradeItemKeyDown","keyCode","newName","value","name","updateGradeItem","editGradingPageData","html","js","handleGradeItemKeyUp","handleGradeItemFocusOut","MoodleConfig","behatsiterunning","handleSlotGradeItemChanged","select","slotId","newGradeItemId","slots","quizgradeitemid","updateSlotGradeItem","handleGradeItemClick","link","actionDelete","actionEdit","querySelectorAll","forEach","instructions","uniqueId","editLabel","rawName","inputElement","add","handleGradeItemEditStart","handleButtonClick","handleAddGradeItemClick","handleAutoSetup","handleResetAllClick","parentNode","createGradeItem","autoSetupGradeItems","button","deleteCancelPromise","reallyResetAll","slotResets","map","length","tr","body","addEventListener"],"mappings":";;;;;;;8NAmCMA,6BACoB,2BADpBA,0BAEiB,8BAFjBA,8BAGqB,8BAHrBA,wBAIe,iCAJfA,0BAKiB,uBALjBA,4BAMoBC,IAAO,4BAA8BA,GANzDD,sBAOa,6DAPbA,0BAQiB,uBARjBA,4BASmB,wCATnBA,yBAUgB,6BAVhBA,mBAWU,2BAXVA,8BAYsBC,IAAO,+BAAiCA,GAAK,oBAkGnEC,kCAAqCC,YAAeC,mCAAmC,CAACD,aAQxFC,mCAAsCC,cACxCA,YAAYC,KAAK,CACTC,WAAY,sCACZC,KAAM,CACFC,OAAQJ,YAAY,GAAGG,KAAKC,UAGjCC,QAAQC,KAAI,cAAUN,cAC5BO,MAAKC,SAAWC,KAAKC,MAAMF,QAAQG,IAAI,OAQtCC,sBAAyBC,IAC3BA,EAAEC,uBACIC,QAAU,IAAIC,iBAAQ,0BAEtBC,UAAYJ,EAAEK,OAAOC,QAAQ,0CAChBF,UAAWF,eAExBK,SAAWH,UAAUE,QAAQ,MAC7BE,OAASD,SAASD,QAAQ,SAASG,QAAQD,OAC3CE,YAAcH,SAASE,QAAQE,oBAEjCC,gBAEAA,gBADAL,SAASM,mBACS/B,8BAA8ByB,SAASM,mBAAmBJ,QAAQE,iBAElE7B,6BA3FF,EACpB0B,OACAE,cACC1B,kCAAkC,CACnCK,WAAY,8BACZC,KAAM,CACFC,OAAQiB,OACRM,eAAgB,CAAC,CAAC/B,GAAI2B,iBAuF1BK,CAAgBP,OAAQE,aACnBhB,KAAKsB,cACLtB,MAAK,KACFQ,QAAQe,UACRC,SAASC,cAAcP,iBAAiBQ,WAE3CC,MAAMC,sBAAaC,YAOtBC,oBAAuBC,eACzBA,aAAaC,UAAYD,aAAahB,QAAQkB,kBACvCF,aAAahB,QAAQkB,WAE5BF,aAAaG,UAAUC,OAAO,oBAC9BJ,aAAaN,cAAc,sBAAsBC,SAuC/CU,uBAA0B9B,OACV,KAAdA,EAAE+B,qBAIAN,aAAezB,EAAEK,OAAOC,QAAQxB,iCAGjC2C,eAAiBA,aAAanB,QAAQxB,gCAI3CkB,EAAEC,uBACIC,QAAU,IAAIC,iBAAQ,6BAEtB6B,QAAUP,aAAaN,cAAc,SAASc,MAC9C7B,UAAYJ,EAAEK,OAAOC,QAAQ,0CAChBF,iBAEbG,SAAWH,UAAUE,QAAQ,MAC7BE,OAASD,SAASD,QAAQ,SAASG,QAAQD,OAC3CE,YAAcH,SAASE,QAAQE,gBA/LjB,EACpBH,OACAE,YACAsB,UACChD,kCAAkC,CACnCK,WAAY,8BACZC,KAAM,CACFC,OAAQiB,OACRM,eAAgB,CAAC,CAAC/B,GAAI2B,YAAawB,KAAMF,aAyL7CG,CAAgB3B,OAAQE,YAAasB,SAChCtC,KAAKsB,cACLtB,MAAK,KACFQ,QAAQe,UACRC,SAASC,cAAcrC,8BAA8B4B,cAAcU,MAAM,eAAiB,OAE7FC,MAAMC,sBAAaC,YAStBP,aAAgBoB,sBAClB,qBAAe,6BAA8BA,qBACxC1C,MAAK,CAAC2C,KAAMC,MAAO,0BAAYpB,SAASC,cAAcrC,+BAAgCuD,KAAMC,IAAM,MAOrGC,qBAAwBvC,OACR,KAAdA,EAAE+B,qBAIAN,aAAezB,EAAEK,OAAOC,QAAQxB,6BAGjC2C,cAAiBA,aAAanB,QAAQxB,2BAI3CkB,EAAEC,iBACFuB,oBAAoBC,gBAQlBe,wBAA2BxC,OACzByC,gBAAaC,8BAKXjB,aAAezB,EAAEK,OAAOC,QAAQxB,6BAGjC2C,cAAiBA,aAAanB,QAAQxB,2BAI3CkB,EAAEC,iBACFuB,oBAAoBC,gBAQlBkB,2BAA8B3C,UAC1B4C,OAAS5C,EAAEK,OAAOC,QAAQxB,+BAG3B8D,SAAWA,OAAOtC,QAAQxB,2BAI/BkB,EAAEC,uBACIC,QAAU,IAAIC,iBAAQ,gCAEtB0C,OAASD,OAAOnC,QAAQoC,OACxBC,eAAiBF,OAAOX,MAAQW,OAAOX,MAAQ,KAC/C7B,UAAYJ,EAAEK,OAAOC,QAAQ,0CAChBF,UAAWF,SA5ON,EACxBM,OACAqC,OACAnC,cACC1B,kCAAkC,CACnCK,WAAY,wBACZC,KAAM,CACFC,OAAQiB,OACRuC,MAAO,CAAC,CAAChE,GAAI8D,OAAQG,gBAAiBtC,iBAwO1CuC,CAFe7C,UAAUE,QAAQ,SAASG,QAAQD,OAEtBqC,OAAQC,gBAC/BpD,KAAKsB,cACLtB,MAAK,KACFQ,QAAQe,UACRC,SAASC,cAAcrC,4BAA4B+D,SAASzB,WAE/DC,MAAMC,sBAAaC,YAQtB2B,qBAAwBlD,UACpBmD,KAAOnD,EAAEK,OAAOC,QAAQ,KAGzB6C,MAASA,KAAK7C,QAAQxB,2BAIvBqE,KAAK1C,QAAQ2C,cACbrD,sBAAsBC,GAGtBmD,KAAK1C,QAAQ4C,YArKarD,CAAAA,IAC9BA,EAAEC,uBACIC,QAAU,IAAIC,iBAAQ,8BACtBsB,aAAezB,EAAEK,OAAOC,QAAQxB,2BAEtCoC,SAASoC,iBAAiBxE,6BAA6ByE,QAAQ/B,qBAE/DC,aAAahB,QAAQkB,WAAaF,aAAaC,8BACrC,yBACLhC,MAAM8D,qBACGC,SAAW,iBAAmBhC,aAAanB,QAAQ,MAAMG,QAAQE,gBACvEc,aAAaC,UAAY,kCAAoC8B,aAApC,8CACwBC,SAAW,KAAOhC,aAAahB,QAAQiD,UAD/D,kCAEWD,SAAW,YAAchC,aAAahB,QAAQkD,QAClE,kDAEVC,aAAenC,aAAaN,cAAc,gBAChDyC,aAAaxC,QACbwC,aAAahB,SACbnB,aAAaG,UAAUiC,IAAI,oBAC3B3D,QAAQe,UACD,QAEVI,MAAMC,sBAAaC,YA+IpBuC,CAAyB9D,KAU3B+D,kBAAqB/D,IACnBA,EAAEK,OAAOC,QAAQxB,+BACjBkF,wBAAwBhE,GAExBA,EAAEK,OAAOC,QAAQxB,4BACjBmF,gBAAgBjE,GAEhBA,EAAEK,OAAOC,QAAQxB,2BACjBoF,oBAAoBlE,IAStBgE,wBAA2BhE,IAC7BA,EAAEC,uBACIC,QAAU,IAAIC,iBAAQ,8DACTH,EAAEK,OAAO8D,WAAYjE,SAhWxCM,CAAAA,QACCxB,kCAAkC,CACnCK,WAAY,8BACZC,KAAM,CACFC,OAAQiB,OACRM,eAAgB,CAAC,CAACoB,KAAM,QA+V5BkC,CAFepE,EAAEK,OAAOI,QAAQD,QAG3Bd,KAAKsB,cACLtB,MAAK,KACFQ,QAAQe,UACRC,SAASC,cAAcrC,8BAA8BsC,WAExDC,MAAMC,sBAAaC,YAQtB0C,gBAAmBjE,IACrBA,EAAEC,uBACIC,QAAU,IAAIC,iBAAQ,0BA1S5BK,CAAAA,QACCxB,kCAAkC,CACnCK,WAAY,yCACZC,KAAM,CACFC,OAAQiB,UA0SZ6D,CAFerE,EAAEK,OAAOI,QAAQD,QAG3Bd,KAAKsB,cACLtB,MAAK,KACFQ,QAAQe,UACRC,SAASC,cAAcrC,0BAA0BsC,WAEpDC,MAAMC,sBAAaC,YAQtB2C,oBAAuBlE,IACzBA,EAAEC,uBACIqE,OAAStE,EAAEK,6BAEJkE,qBACT,mBAAU,6BAA8B,SACxC,mBAAU,6BAA8B,SACxC,mBAAU,SACVD,QACF5E,MAAK,IAAM8E,eAAeF,UAC3BjD,OAAM,IAAMiD,OAAOlD,WAQlBoD,eAAkBF,eACdpE,QAAU,IAAIC,iBAAQ,0DACTmE,OAAOH,WAAYjE,eAEhCM,OAAS8D,OAAO7D,QAAQD,WAE1BrB,YAAc,SAGZsF,WAAa,IAAIvD,SAASoC,iBAAiBxE,4BAA4B4F,KACpE9B,UACG7D,GAAI6D,OAAOnC,QAAQoC,OACnBG,gBAAiB,MAEzByB,WAAWE,QACXxF,YAAYC,KAAK,CACbC,WAAY,wBACZC,KAAM,CACFC,OAAQiB,OACRuC,MAAO0B,cAMnBtF,YAAYC,KAAK,CACbC,WAAY,8BACZC,KAAM,CACFC,OAAQiB,OACRM,eAAgB,IAAII,SAASoC,iBAAiBxE,wBAAwB4F,KAAKE,KAChE,CAAC7F,GAAI6F,GAAGnE,QAAQE,uBAKnCzB,mCAAmCC,aAC9BO,KAAKsB,cACLtB,MAAK,KACFQ,QAAQe,UACRC,SAASC,cAAcrC,8BAA8BsC,WAExDC,MAAMC,sBAAaC,0BAoBR,KAbhBL,SAAS2D,KAAKC,iBAAiB,QAAS5B,sBACxChC,SAAS2D,KAAKC,iBAAiB,UAAWhD,wBAC1CZ,SAAS2D,KAAKC,iBAAiB,QAASvC,sBACxCrB,SAAS2D,KAAKC,iBAAiB,WAAYtC,yBAE3CtB,SAAS2D,KAAKC,iBAAiB,QAASf,mBAExC7C,SAAS2D,KAAKC,iBAAiB,SAAUnC"}PK9B\"amd/build/reopen_attempt_ui.min.jsnu[define("mod_quiz/reopen_attempt_ui",["exports","core/notification","core/ajax","core/str"],(function(_exports,_notification,_ajax,_str){Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.init=void 0; /** * This module has the code to make the Re-open attempt button work, if present. * * That is, it looks for buttons with HTML like * <button type="button" data-action="reopen-attempt" data-attempt-id="227000" data-after-action-url="/mod/quiz/report.php"> * and if that is clicked, it first shows an 'Are you sure' pop-up, and if they are sure, * the attempt is re-opened, and then the page reloads. * * @module mod_quiz/reopen_attempt_ui * @copyright 2023 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ const reopenButtonClicked=async e=>{if(!(e.target instanceof HTMLElement&&e.target.matches('button[data-action="reopen-attempt"]')))return;e.preventDefault();const attemptId=e.target.dataset.attemptId;try{const messages=(0,_ajax.call)([{methodname:"mod_quiz_get_reopen_attempt_confirmation",args:{attemptid:attemptId}}]);await(0,_notification.saveCancelPromise)((0,_str.getString)("reopenattemptareyousuretitle","mod_quiz"),messages[0],(0,_str.getString)("reopenattempt","mod_quiz"),{triggerElement:e.target}),await(0,_ajax.call)([{methodname:"mod_quiz_reopen_attempt",args:{attemptid:attemptId}}])[0],window.location=M.cfg.wwwroot+e.target.dataset.afterActionUrl}catch(error){if("modal-save-cancel:cancel"===error.type)return;await(0,_notification.exception)(error)}};_exports.init=()=>{document.addEventListener("click",reopenButtonClicked)}})); //# sourceMappingURL=reopen_attempt_ui.min.js.mapPK9B\Aamd/build/preflightcheck.min.jsnu[/** * This class manages the confirmation pop-up (also called the pre-flight check) * that is sometimes shown when a use clicks the start attempt button. * * This is also responsible for opening the pop-up window, if the quiz requires to be in one. * * @module mod_quiz/preflightcheck * @copyright 2016 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @since 3.1 */ define("mod_quiz/preflightcheck",["jquery","core/yui","core_form/changechecker"],(function($,Y,FormChangeChecker){var t={confirmDialogue:null,init:function(startButton,confirmationTitle,confirmationForm,popupoptions){var finalStartButton=startButton;Y.use("moodle-core-notification",(function(){Y.one(confirmationForm)&&(t.confirmDialogue=new M.core.dialogue({headerContent:confirmationTitle,bodyContent:Y.one(confirmationForm),draggable:!0,visible:!1,center:!0,modal:!0,width:null,extraClasses:["mod_quiz_preflight_popup"]}),Y.one(startButton).on("click",t.displayDialogue),Y.one("#id_cancel").on("click",t.hideDialogue),finalStartButton=t.confirmDialogue.get("boundingBox").one('[name="submitbutton"]')),popupoptions&&Y.one(finalStartButton).on("click",t.launchQuizPopup,t,popupoptions)}))},displayDialogue:function(e){e&&e.halt(),t.confirmDialogue.show()},hideDialogue:function(e){e&&e.halt(),t.confirmDialogue.hide(e)},launchQuizPopup:function(e,popupoptions){e.halt(),Y.use("io-form",(function(){var form=e.target.ancestor("form");FormChangeChecker.resetFormDirtyState(form.getDOMNode()),window.openpopup(e,{url:form.get("action")+"?"+Y.IO.stringify(form).replace(/\bcancel=/,"x="),windowname:"quizpopup",options:popupoptions,fullscreen:!0})}))}};return t})); //# sourceMappingURL=preflightcheck.min.js.mapPK9B\x__amd/build/repaginate.min.js.mapnu[{"version":3,"file":"repaginate.min.js","sources":["../src/repaginate.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 * Initialise the repaginate dialogue on quiz editing page.\n *\n * @module mod_quiz/repaginate\n * @copyright 2019 The Open University\n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\nimport Modal from 'core/modal';\n\nexport const init = () => {\n document.addEventListener('click', (event) => {\n const repaginateCommand = event.target.closest('#repaginatecommand');\n if (!repaginateCommand) {\n return;\n }\n\n event.preventDefault();\n Modal.create({\n title: repaginateCommand.dataset.header,\n body: repaginateCommand.dataset.form,\n large: false,\n show: true,\n });\n });\n};\n"],"names":["document","addEventListener","event","repaginateCommand","target","closest","preventDefault","create","title","dataset","header","body","form","large","show"],"mappings":";;;;;;;sJAwBoB,KAChBA,SAASC,iBAAiB,SAAUC,cAC1BC,kBAAoBD,MAAME,OAAOC,QAAQ,sBAC1CF,oBAILD,MAAMI,gCACAC,OAAO,CACTC,MAAOL,kBAAkBM,QAAQC,OACjCC,KAAMR,kBAAkBM,QAAQG,KAChCC,OAAO,EACPC,MAAM"}PK9B\rj UPGRADING.mdnu[# mod_quiz Upgrade notes ## 5.0 ### Added - quiz_attempt now has 2 additional state values, NOT_STARTED and SUBMITTED. These represent attempts when an attempt has been For more information see [MDL-68806](https://tracker.moodle.org/browse/MDL-68806) - New quiz setting "precreateperiod" controls the period before timeopen during which attempts will be pre-created using the new NOT_STARTED state. This setting is marked advanced and locked by default, so can only be set by administrators. This setting is read by the \mod_quiz\task\precreate_attempts task to identify quizzes due for pre-creation. For more information see [MDL-68806](https://tracker.moodle.org/browse/MDL-68806) ### Changed - quiz_attempt_save_started now sets the IN_PROGRESS state, timestarted, and saves the attempt, while the new quiz_attempt_save_not_started function sets the NOT_STARTED state and saves the attempt. For more information see [MDL-68806](https://tracker.moodle.org/browse/MDL-68806) - quiz_attempt_save_started Now takes an additional $timenow parameter, to specify the timestart of the attempt. This was previously set in quiz_create_attempt, but is now set in quiz_attempt_save_started and quiz_attempt_save_not_started. For more information see [MDL-68806](https://tracker.moodle.org/browse/MDL-68806) - The `quiz_question_tostring` method now includes a new boolean parameter, `displaytaglink`. This parameter specifies whether the tag name in the question bank should be displayed as a clickable hyperlink (`true`) or as plain text (`false`). For more information see [MDL-75075](https://tracker.moodle.org/browse/MDL-75075) - The `\mod_quiz\attempt_walkthrough_from_csv_test` unit test has been marked as final and should not be extended by other tests. All shared functionality has been moved to a new autoloadable test-case: `\mod_quiz\tests\attempt_walkthrough_testcase`. To support this testcase the existing `$files` instance property should be replaced with a new static method, `::get_test_files`. Both the existing instance property and the new static method can co-exist. For more information see [MDL-81521](https://tracker.moodle.org/browse/MDL-81521) ### Deprecated - quiz_attempt::process_finish is now deprecated, and its functionality is split between ::process_submit, which saves the submission, sets the finish time and sets the SUBMITTED status, and ::process_grade_submission which performs automated grading and sets the FINISHED status. For more information see [MDL-68806](https://tracker.moodle.org/browse/MDL-68806) - The webservice function `mod_quiz_get_user_attempts` is now deprecated in favour of `mod_quiz_get_user_quiz_attempts`. With the introduction of the new NOT_STARTED quiz attempt state, `mod_quiz_get_user_attempts` has been modified to not return NOT_STARTED attempts, allowing clients such as the mobile app to continue working without modifications. `mod_quiz_get_user_quiz_attempts` will return attempts in all states, as `mod_quiz_get_user_attempts` did before. Once clients are updated to handle NOT_STARTED attempts, they can migrate to use this function. A minor modification to `mod_quiz_start_attempt` has been made to allow it to transparently start an existing attempt that is in the NOT_STARTED state, rather than creating a new one. For more information see [MDL-68806](https://tracker.moodle.org/browse/MDL-68806) ### Removed - Final removal of quiz_delete_override() and quiz_delete_all_overrides() For more information see [MDL-80944](https://tracker.moodle.org/browse/MDL-80944) ## 4.5 ### Added - The following methods of the `quiz_overview_report` class now take a new optional `$slots` parameter used to only regrade some slots in each attempt (default all): - `\quiz_overview_report::regrade_attempts()` - `\quiz_overview_report::regrade_batch_of_attempts()` For more information see [MDL-79546](https://tracker.moodle.org/browse/MDL-79546) PK9B\ loverride_form.phpnu[. /** * Settings form for overrides in the quiz module. * * @package mod_quiz * @copyright 2010 Matt Petro * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @todo MDL-76612 delete this file as part of Moodle 4.6 development. * @deprecated This file is no longer required in Moodle 4.2+. */ defined('MOODLE_INTERNAL') || die(); debugging('This file is no longer required in Moodle 4.2+. Please do not include/require it.', DEBUG_DEVELOPER); PK9B\-u comment.phpnu[. /** * This page allows the teacher to enter a manual grade for a particular question. * This page is expected to only be used in a popup window. * * @package mod_quiz * @copyright gustav delius 2006 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ use mod_quiz\output\attempt_summary_information; require_once('../../config.php'); require_once('locallib.php'); $attemptid = required_param('attempt', PARAM_INT); $slot = required_param('slot', PARAM_INT); // The question number in the attempt. $cmid = optional_param('cmid', null, PARAM_INT); $PAGE->set_url('/mod/quiz/comment.php', ['attempt' => $attemptid, 'slot' => $slot]); $attemptobj = quiz_create_attempt_handling_errors($attemptid, $cmid); $attemptobj->preload_all_attempt_step_users(); $student = $DB->get_record('user', ['id' => $attemptobj->get_userid()]); // Can only grade finished attempts. if (!$attemptobj->is_finished()) { throw new \moodle_exception('attemptclosed', 'quiz'); } // Check login and permissions. require_login($attemptobj->get_course(), false, $attemptobj->get_cm()); $attemptobj->require_capability('mod/quiz:grade'); // Print the page header. $PAGE->set_pagelayout('popup'); $PAGE->set_title(get_string('manualgradequestion', 'quiz', [ 'question' => format_string($attemptobj->get_question_name($slot)), 'quiz' => format_string($attemptobj->get_quiz_name()), 'user' => fullname($student)])); $PAGE->set_heading($attemptobj->get_course()->fullname); $output = $PAGE->get_renderer('mod_quiz'); echo $output->header(); // Prepare summary information about this question attempt. $summary = new attempt_summary_information(); // Set the caption. $summary->set_caption(get_string('summaryofattempt', 'quiz')); // Student name. $userpicture = new user_picture($student); $userpicture->courseid = $attemptobj->get_courseid(); $summary->add_item('user', $userpicture, new action_link( new moodle_url('/user/view.php', [ 'id' => $student->id, 'course' => $attemptobj->get_courseid()]), fullname($student, true))); // Quiz name. $summary->add_item('quizname', get_string('modulename', 'quiz'), format_string($attemptobj->get_quiz_name())); // Question name. $summary->add_item('questionname', get_string('question', 'quiz'), $attemptobj->get_question_name($slot)); // Error message in case of input invalid mark. $submiterror = false; // Process any data that was submitted. if (data_submitted() && confirm_sesskey()) { if (optional_param('submit', false, PARAM_BOOL) && question_engine::is_manual_grade_in_range($attemptobj->get_uniqueid(), $slot)) { $transaction = $DB->start_delegated_transaction(); $attemptobj->process_submitted_actions(time()); $transaction->allow_commit(); // Log this action. $params = [ 'objectid' => $attemptobj->get_question_attempt($slot)->get_question_id(), 'courseid' => $attemptobj->get_courseid(), 'context' => $attemptobj->get_quizobj()->get_context(), 'other' => [ 'quizid' => $attemptobj->get_quizid(), 'attemptid' => $attemptobj->get_attemptid(), 'slot' => $slot ] ]; $event = \mod_quiz\event\question_manually_graded::create($params); $event->trigger(); echo $output->notification(get_string('changessaved'), 'notifysuccess'); close_window(2, true); die; } else { $submiterror = true; } } // Print quiz information. echo html_writer::div($output->render($summary), 'mb-3'); // Display notification if current mark is invalid. if ($submiterror) { echo $output->notification(get_string('savemanualgradingfailed', 'quiz'), \core\output\notification::NOTIFY_ERROR); } // Print the comment form. echo ''; echo $attemptobj->render_question_for_commenting($slot); ?>
                '; $PAGE->requires->js_init_call('M.mod_quiz.init_comment_popup', null, false, quiz_get_js_module()); // End of the page. echo $output->footer(); PK9B\懂tests/fixtures/moodle_logo.jpgnu[JFIF    ! *#'2*#%/-7;,/.5884%*8A0*<<,45 5$$555555555555.555-5555554,2555555)4),42462,5442444500"5 !1QAasCb#$6Bq"1!Q ?qR?% }IV,+LN\C儺|e[u&})P#-J(c-dH?L!=<hvzstK^ {701hI#sG:LDb6ӊZ}EM@JqVS.x6=i:/YgiG9ס)0.$l7dpX̕-wt,%?xJw3Cx JFۻ00sjV&変e*qn7ڦ+gS2q. Iȣ `xnB5ƙj}^RI( uXfX]ΰ(_^sTH`ޠ=+_Ǣ4yH %]`qQ^=&3V┕\R$`_,ߗϕ_|ߘSN7cm$/::jʹIPP;/8#͝:i'U9*n̾ul'eH"Vm]\%$(!$l6;t "ե~PK9B\(VPPtests/fixtures/questions01.csvnu[slot,type,which,cat,mark,overrides.penalty 1,multianswer,,maincat,100,0.3333333 PK9B\2еkktests/fixtures/results00.csvnu[quizattempt,slots.1.mark,slots.2.mark,slots.3.mark,slots.4.mark,slots.5.mark,slots.6.mark,slots.7.mark,summarks 1,0.8,1,1,1,0.5,1,0,5.3 2,1,0,1,1,0.5,0,0.5,4 3,0,1,1,0,0.5,0,0.5,3 4,0,1,0.3333333,1,1,1,1,5.33333 5,1,1,1,1,0.5,1,1,6.5 6,0,1,1,1,0.5,1,1,5.5 7,0,0,1,0,0.5,0,0,1.5 8,0.8,1,1,1,0.5,1,0,5.3 9,0,0,0.3333333,0,0.5,1,1,2.83333 10,0.8,1,1,1,0.5,1,0.5,5.8 PK9B\"VV%tests/fixtures/random_by_tag_quiz.mbznu[=i6+X9ꭟ(8ɎMlVMT IG2w/8HI-1E@wt//~g?$6Ac?&mZ6C 4QCUAr>SE 2<\7X82^dUZ>3e|1*F2} ̃VqQf:yx :elqr3`/Ԧ;[:<죧(xMߗ!Aoy29ir{ %g5G=jKk:y^W†BUe|_TuɝLjYI$Sh4W~AZ'0yq?#9Y.u:?K%?=?gL"GA̿9&5nP^`x( (NQVA%x\AUfE5ka6(('F(7qVӨ,dArAYc#u`4(xݔ,sJfiDV$1F wN2:H6b PŎ{ 4ʕ֭c'#(LQQLm19 A~R2LSlpqȧH!o!K)GB a͡x"HdLʗd`) b4%kiEB oK#T)$րć+V=.IX%evǖ./)gNJS$P?=<)-)jAFAKȁJ^^Y.AmAMaet -ؒhax[Do\"kC4miTGGqI{j ? ixYCfbbb(+E$x9IdS˴|wF8X5mg3!G68r$N=4~kHu2aKYB_ON$6S AQ0L@j7 طk g }?]'dC7{G Zy0AIx:-mYA1e;_K{5`I(O6ANG FM"/pbKbQ% 5~-66 Z;1ۉ$;1Ą8=!*08)R&GxTfUdv$urSD5jRQU^tg d(~*ͮiub0}9 ؽfwtrOZ'LTAe~VJP.veՕNkB곧%((Ժr"tcy'3gKܬrm Ԧz`z5D4YęYȍ۵`] @3fff"2;|xIdFoq-cͳrl4*V:^_U^‡Ds\:(pyO7}z)Z>b}Iyl8 8pr77=?tl0R:*8#G& xw6qX)w[Hnk.tiv+Mw kD[ l+A] J [^g"ovf=^Mk בBTQIrX  ElUVI$ؗ"`Z75bܶfGm&)/OPXq_ ચ h#6\PhRo{1^Dр My0*뭕a\E\&-d|k6B4gk h9Rh* L 3 a$j~MJJA/')LC p 4Km]?IY;ܬ-SxIg^KqrTQcܙJqΪly֮_oՓT/΃,Yjμ_7#,izݍgySt 9n3%jǍf yB(ӵv!&O= g }|"Ә3.Cwi5ձ{[&%3ۧ# xPyݭ6"4;"9!LMO7ڰ3-v!.οԶ+N%{n~v2bt,OT5ɽ;&{m#mnv vӣꉇs`BW_;WPo)-*) 5e7+dDu˚&6=7ȈSz'?]u߽z2YǧG{<`,},oCs%?!9 tZl_e٬[-G_cRueh\,.,*:NFOx%(]2:&P;Wc]~닟j,d|y96"{ȱ:=#-.DmY {r_$(4#IY33% gjPƷ<]op]I 3kS+r]l7WM_vln˃jWMpn_  εa|wo?]¹^NN߫_svJ8@IA+iAҜCҀ7`Iskt)w|iȿI3pEҌA_I3#I4f_Ic ƱeDV;˥y ilx<%'_ICMUҘ%KCajגXX(+᜾$9t)Ӻץp#H8Rːp'\A gpg,4A4p4ХLҷo.iݭg6c#H-KǸ/I p }EM:!_7ӫZ B;kny 1oY/T(ͿN./G|e^okc1'{˟?|;Ҏ<DćOtvWÙ }2>r~ǿ\i/2_O]{Iۂ=tgppv?O7ݝ!ly:u?K mrg翾79f{{XZ ʍ}oj^&Bj՗UadAI(Ͱ7#l{RQZao/:iЗ#6 M? Vy\N~|T#̻hݎ}7ATTk1 wi:F qw=3sYzkK"O;q0> 02M3 ]OSnjgG~qƟ:?M 3J!šmC}T-1rǎ?w1\37ŖjsTKH2^̳4{4o{|G9Dx"8! w)PK9B\+AAtests/fixtures/quizzes.csvnu[testnumber,preferredbehaviour 00,deferredfeedback 01,interactive PK9B\h g  tests/fixtures/questions00.csvnu[slot,type,which,cat,mark 1,random,,rand,1 ,shortanswer,,rand,1 ,numerical,,rand,1 2,calculatedsimple,sumwithvariants,maincat,1 3,match,,maincat,1 4,truefalse,,maincat,1 5,multichoice,two_of_four,maincat,1 6,multichoice,one_of_four,maincat,1 7,multianswer,,maincat,1 PK9B\ )lltests/fixtures/steps01.csvnu[quizattempt,firstname,lastname,responses.1.1.answer,responses.1.2.answer,responses.1.-submit,responses.1.-tryagain,finished 1,Juniper,Jones,Pussy-cat,Bow-wow,1,0,0 1,Juniper,Jones,Owl,Bow-wow,0,1,0 1,Juniper,Jones,Owl,Bow-wow,1,0,1 2,Juniper,Smith,Dog,Pussy-cat,1,0,0 2,Juniper,Smith,Dog,Pussy-cat,0,1,0 2,Juniper,Smith,Dog,Pussy-cat,1,0,1 3,Juniper,Vicars,Owl,"Wiggly worm",1,0,0 3,Juniper,Vicars,Owl,"Wiggly worm",1,0,0 3,Juniper,Vicars,Owl,"Wiggly worm",1,0,1 4,Juniper,Pacino,Owl,Pussy-cat,1,0,0 5,Juniper,Jones,Pussy-cat,Bow-wow,1,0,0 5,Juniper,Jones,Owl,Bow-wow,0,1,0 5,Juniper,Jones,Owl,Bow-wow,1,0,1 6,Juniper,Smith,Dog,Pussy-cat,1,0,0 6,Juniper,Smith,Dog,Pussy-cat,0,1,0 6,Juniper,Smith,Dog,Pussy-cat,1,0,1 7,Juniper,Vicars,Owl,"Wiggly worm",1,0,0 7,Juniper,Vicars,Owl,"Wiggly worm",1,0,0 7,Juniper,Vicars,Owl,"Wiggly worm",1,0,1 8,Juniper,Pacino,Owl,Pussy-cat,1,0,0 PK9B\5Gtests/fixtures/moodle_39_quiz_with_random_question_from_mod_context.mbznu[i62__!<=g:Ni)v(P,ѶYru~y_-9ϖVbqf13$o?~OLƮa-1X雮nA0S5TE(},`yoa$N#pcΪ|a ,۸A1z=5!T >fQmt7oӱ3&rKPY Jn9~ 5_ a4 rk_A$cO&](%3mʶ;P4-t`EǕQ (,e4H,'O$,{ӱjr(IX: |_(덕fU Kϋ7Q V %e-P*֨RTz5Hr6o[V")89r'Oģmg\Ӷ]8=cf`?pNGɗW֍yeE\O^͈͟PqʷD۴aZi^͟K-#y$ Dqm61 V%&GS"#'Q9݉56nZ"B۸ SgQ`-(`BWOA ȿBseYZئKQB.ьpOma Xy >`p= Jd(!(-v[-PaHD x I"v* (32+%$tImv 3jE90m ǒyD!+p95aBhW"0Bb!WfiDF^؂raN6HvbnF*2ka3hd2TLL)(x626߫"pWqȺH!!k|Ec2n (@^,^-V,kK2i8M0IX%FI{Qŵd.J>Lp{HӹF .#QaХB#` +6Vx 1r_UIb@1]R3`,Xsƍf`%huĞ(D Ò–H&4o,e`O۰ y%O"^Լh6q NOKF- .jrtq9nυ[ ٫Ƚ#/dEY SJ\i ኔ٥B36<[=PH&SWי*HX C%3]Mݱ;{ ]r]B5H .pixgk{^BWB-sr[Sm j(=!mo͢7c*dh[sz G>KKWgG~<j?#ۯprg_C8g9~gq3hg?pm,xYL8Q| &7I=q:`&%+7hۛ|d02F^$^) n[i ߼,yK2|S}y,Mp &p= }hn ..oyGrLȇ QOW4!KçA|Ohzf8 ٗQDLGq@R*9 [#"Pʬ*DIEJQirQc 99r'ug?9P oqQCP{a <ev3'tMxs+>H( }͎Z^5:`lv,ۚ^[cۿNR}򢅚'l=$ (ܹ؞UD/ f$5)pi(^ 3$2,'6%LJ-ԉ cX9N$ @{;9aW|,+盲/F >&+H:I'gÇ|b;L0U.-r'So:R5t=RJm&2.z:ũy u/}sv.O$ؓ!{Ω9A"9F4qc9RRzb֥桀sz o"#US ]`tx8mo9k`%?WuF("F=bX\o CZ&~P{VAHi `9P ؊DiUz^ⴘĉ?A{6ol/j}MTD\&{n L/Ȫ~C m5U5S׎ e[̑Em-p u1ɗ:JkP?|C G=40HaiT sy:WX4g~= 0.? Zmسpd}/FPZNu,o ga.}K\L=++ v.i WyrbҬϴ>d tz&:e!IBSP.W7sGxMg?Qx_+/Nq.?zN$/Q794 ,hC 5>pCM B=r&A(\~7xٞi?)!'I5S=&0kG=G `߅=+ XURbLϽ#5A`ؑ'{xkoK#r AԮ= z7z[6QeIΈURWFQ>$]IN_ X_^}.0JBY7+2GkC_BexH*05BЌ ϫ`o@$p 2TSP"A<h HT,;c{H/YM _^}Kwh<(MƃbFQ1NC&|@. `i$e!o- O &?РX 4V4%^?a5b7uhO7@kL'oiS$fƅFM&PADTY0B8q $b/f XHTf,:۟O&  yãyHe}24R#UÔoM{}02xa_t:vYԁ?A8}4V{^Q9kKWfG|o? =OD3u५3#z#gѢXO/w0 M w7 .2G"(FۉgXD`Y7؝J)[ȴ 8t#Ay }4{`KP&)ۭ(Ɲ偎OD (IEg[{-E`'d njF.I]}5=.KQm ,8tV8p?wBk>uPVy=PBCZq H_z<9i[t;iTVݾt8č*er"uC>PK9B\'002tests/fixtures/question_attempts_missing_users.mbznu[Spa}۶p~ kkwzkOQʓ'/y+N*Nl'޼RQ$$ѦH?] h4ݍFzۗ{_ӫ_i6etCs40-a8_ TGuy ʮ?Lܛ8C0po\< 8o:^|} +L/^Cqv~=z|M˱usdV$y9öFa+e)7/efqrG@37JtVb;ZCx³7470|k؛af i]} A ŷa D^ 9 ae40j!Vf,Q3V$hiDeZ!'WR 8(DlцNNLVIT6nʍrH4,M"Z$au"F%$xҖU,?t3[T]$qfُr >k^5ǒq< 7RjWR-RNo $x8SFq8VCv=XP:]@A3W`5#ʣؾ ?kQFO58t>4M<#4OTb?/g^>HaBz8S Vi=õJO R詟Ҝ\$BDxvKa񱊓`ərf*fs3L)J.Fܜs9X9Tq MѤAQmp[*G|vv-Hv.^ ug1\[WBRRKῃ=j H>>Q8OѪgZ,X,ܮ$#鸟/'Dj/}m8ɕl N  LkQk+&Q"ل!%ZXbm 9F紌c}i8cߋqso~<gދog= c98G`C:):T^nVǽd3t]??k;TT:PE 4 5rDJ`-Dn}Q[C_=k*h<fh5BRȅ:Q%EO(_͠zh xi-8Jp%Ë g++CăVCC f!@b^ Gd]( XF}+4װApuDG&|GB(RlAJ!5k#H-#z5ܠ  ꍰcXq; q,8^xI߃zBh1nj]yY[3,3^ŵ*0#)l fMzn*,t/JURP9],CI5x< DO\z p,\,2@2;8lDjK"-YT,o*'(Ua@ap.S KOr҃FRŁ5ҫU{lS~%ӿ ^> ~^?ëp~R+@{N)d֗c<7ϵh;Zt7nd{QImH۪!mH%S$+ ͲH,}8)zN8Ew/Μ,vd "$ m G4 $YD܊2J"jpuJJ{*TjwTz*Tj"چDwTDK:f7lGn:C$-&(UG8ͳ Tap4MV7ɱt㑉"rwbUl9ar2_ʊnvPGN7 lHPld{En/י[_^ > ~|y_e.F6{"c%aԊ6wSZ7u:bpMnQ7)ExỳrG;'q' }yn~$򬘻r$ۺwԳ!ΖZi?w߉6K7ten$mus7S}lr6gU3o S݉wW su6wgvPsr烚o ݏDsw;ݍnF[|s^ qJNY@+swAF>wN1woɱFg>w@oBl~?BQ8vs#xSGb<7w?yVanux`Գ!Ζ~~.W2l7YOvM zlЕ_i^G]mt}GJ'wY[S7^̩Ib^Z$< !Iݭ$hz7,j@ "1W +U7Jcy8<,p C&nP:}q@g.Jo\(nQ~ڢEgpi,uޡ G/Xa*5/1=?ڇ Cgr Ep A]sE%q)M/av,m$DH(a1K2cߑQޒȭߓ)bxS'oTHSH U`!ȢTD`U:%hyӬ͊2oֿڤu9k $t%'sgg2ؖ;k%)ȝv%wVl8mBByiYWxCTf)GΚ #YrgI;W)NΝge2SjE:YTF b/Nm+whl|9kkT)t 0 ` *1H\ѐ'_sok1\[K֘a^\ǫ5 Zn5C-ȓh% A Nٕ70{?v]>AG_Xl|# YaH=4 Hj3au IEvZ]ǣ훓2ÖeÕ2M>"kXyq^S.@7LX%vF '9 9au983`y Xv<12C@UEWu$Yq3m6le&ĉ"]->;%M xy^26(G68M [:>Y_ 2e o3nKs' >.9%-GPQM #;nwE#y}) 3׶Ab=eľRTi$ c<_f|#x$,8k~tq$Ydc*H&"R "'DjA%a"yXo֣ 9ͅ5-+"xO1 I3;" E٘^%RJNK0:A**Mct)xpK>P(b%- cbbɤPMmzvVRoTpɨSՍ@R_)RUWmܻjdUԩ U^*ڠ4P9F|&Q/xK6A hҀB46y-ڜ҃cb|JLCd>/ &Mb|*DH5GFh|SU{[uVOdk+jL>R!WY-zE"^IFx-jzM%pEjn|ŶJ*[+P)n@v[,ڷxts)UlkߏܸPlOgvYyx+۶'QlPl-[b[[۲1Wlbvu8UF,TtnzHSlv{<־ ۹uٞdxU톋fxxT[q '䨅?{|](.5Q\*3=c?7s~_{(F/NQ\['t$ۋu'r{ c-"#O9ґ.8hh ? he 8ui(fC]+2?k}#'lAv ' rFN}8zF,. bh6b/XI*(x}=iT( 4H ,EXEʖ Hq_q N!~oX4Fz% ^#L3ɰ8Kd!׻/%O$NxD$Qg*_V3Ǖ{&(.Xl7cRG96 ڶ0o@[۷E<-ۢ1k %AD eUZѠAA{:֤ZS5ZZӖR,JG$4RkZԚd$&ibssLTک_ì69vќ{ K S0"q!NĶF 5xVK<%ް XH%*BHc}e[)Mj07`5PƵ0殔1xu՟6L8j^SWw;;Hl`^;k@ :Uz'cdAp,"Yq턲nd;lW:|Yqr%@V8ĚG:Tn}JD: j%3l*,@`>qs il8v(ntȒ3)U$Öj/F< I$ap<ťGOe %`G!X<Id5lg Stzx~@'d% B~73 e ]?(-n88' /hVy/oؙAgn:@~L,۔`q>;P!!t4+Q}7A6!Ty%¹z[FĞtQk:lgO*V~t~X'c8i9 vH"psgn ԋ-p+P#TJ J2 S,D 8';DF8*p,ṘP{& O (@9A>#wC0$V$39jk~G5_}/#.p"$0Ө,!j # +}SOzBr ^q_Wr]sw_ nt&g;xx//^yH~EiK1sZ\7V+•قnǭ閪Ǧ_Vn+:fi#A돀?~Is?Y>ߜ=*Wv=&j} @O#VQSHz||j?>jΟZfjtA{G6P+F>J;' g˫J_B:]^7ڏF7:W]EڙᣢͣΔF٫?ߠ;4AMу{NMq<DkW0ƺA-Գhj ]80w*ybݕmXŒ6:EFuk Jr yP#7baygoهv+WPՃgY $KV %A<AbA Ҏ^Vi^v { Śԃ[?8{AZI-zA=JĂAX'>?x9׺U^ri\7ZCxAw;R1yS.o%pJ|}^ONGEDo~Jў?̷a|Cj|~xxkˉwS5۟%kwiw;ɪݤF= ɺaѩQ=٩^vvPRnX%k35I-zyR:ݤRKnXU%k35I#<eX—Nѵ5%lJx)MlC׫!Зtom^ߡ-ap :im,av[dIT}%cS۞ڴ;IV5#;z`T}l7q+KB=o[C8l}Fg)ŚăkSyRvCٽ&X48VbE LH 'ڞ1n.VJJvŸtsۗBLxݝY6"^wD\~WwhzWpW?p5TҏG\黛7w^}y0pgJ'9L?> nEŸR (]aSJ*wD 0nvLEmDvPn߼aմs O을T>~Mt3*N6~:54Yl¶z<"w>5d=0`vm|=SB6~;5Yq 9SJIրn[zE`K &-Zzb|(803l=ձOtyV5ͬ0rU 'BPyNTo)$,O/sDQ5 䒪WX6`pKJ.xQq&\ٴf*+ٴGŦ= V/]Q%V!LKxM!hNiq D!lA2!E[eD FX*3A.\9)k7̱x 媈)Omݽ"o^q1tc?N^&}A[ű)RiSGqm_%7gГj33}df_dmA+s Mcy&X%IB|og7R><9$Ie%B5qʜS\G&G&U2xglo x0%w0IPFyA,[GДD褀8 hR5I8\f 4refߌ̫WHBwyOdt53ؙnFlsp )2}rlZN-YN֔Ӳ/q ,%3<~J.kC s%\@KrE(*և Z[#O2lDK[W %lOЦ~5eN]ml>˛'4< X!J ,"3w&E TPT}r!qDREy®Ta%d,U/$/ЈBjW0[CLk0zvX%h B*΁Ơ=RP/M$/ԃe QԉbL.D9,WRG6SfYֿtD1Bق;DV&3bj>^,_E {Ʉb R/ ָHq.O[xMJ=H:>Խt8ݞ^z0?Oxݖ>>`hǶ7 9e@_-}:q|} Wx O'h5kz{vDF}=u{Ww>q4ؚg4![2}5?p`g2{^<5 !AhQ xBgI[*zū齿PR>#d|wr.cR_W)2"t2A#A 5.4 "3T$CϰhM|?ijC}t?xt7ʰd>]XRw-pueyf2/$,DH2]7D̽lyr Gl!~2qfK0l@9`u CPÅz](թPKMo c0E~J5y Uqil G_  c:a!c{~ O@/ -! 9%dqAcRHDxZ |K#|(wu@.! "EhڰbFZP o(e fArϤ\)ZOj|?ZP, œۅ/ Xis= 9]w'n%[R=8 A2]*1m}"XT*b3(f=t9IIIKAX ax|=>!mObzYBco 5a j5`j W@M^M0q;5#&l`[ vsPt$zs=A/+EU:j NmyN` (jڽ>`h ߖ:Gߘo4k_b߿PyB,XB=r'M`/=`WKYY<5m>a|fjux2BU^ja]2 [/ʤ1xg*ep걗ϰ[|N8]bug#jNi3˷=Ss54L S;sÜו9fī 59ܧ:~SL?9ki9[ȘÑ󁩍ʁpV@O1ܹ]Zi/Hh^Bٲ6k̛i3G3w39BW5^|ԙ6.d _-o64]tf3r=mغ m~j w]ulPK9B\CLL"tests/fixtures/moodle_311_quiz.mbznu[=i85Bc2-;xv7@v-,Ѷ6#~y_n[!&@UEEVETo?i/?ΒL|aZef`"Wy権간MGq -0 $Y o&_<6PgʐManoG0&}|fcI`[A)Ⱔ`y)@ (<{w2O%= p뤪2݅6yܤR:ɖy q1Ay ?uL:/ug<s@`Ec)Xc|F5wayTz߀ Z+eU*W6$y94mop")}{8=rҮT<k:_Y gp_i-hp|ueݘW8V)9_g cJ  į+( I<#)P,܀]j˚#8F& D4hbD $,AJ^iRYrffZ2Ͽ͛?UA& .C{#jY+R2Qo +2 z,%B &fI"`h 0 jy)4*ªxa#4i3QI0FVMEA2 ժ+iQVGl)`F0HV:R&1@2Ax?%l[ Gچ]3 'UQ J6aZIAf1Pii1B'Q'P!3)b/g JAP{k't$eZl2 ERȴ"^pԆ2R/<ɇ74h{$p'jOOm<{>ҡMY9粌ZKN;NOc[߶AH? i;T;`7{*Ӂ*P& .c[p#THmCSU(ӮSأx`{I? z/mh&>dK@U򌄢Gm0FFyO5&&UH:{_p%(`y  /XaFHsF UIȸR1w@T+HӶ mT3Jþ~vbDMxf@s)H92lC WeRVʞd!2|n>n>d5dN;Y%{mI-ɤ _jkI-ɤ 6I=n_pp6(nJQ)+!2` B$)dOJsZ`5huqʶt4bЮ6LvS=zT\Ju8DQ 1YmW3^o!񵉾fH9?R|\ f!ch8_HotKKΐL<y zIGv_7xk>wo@# _vRtw|O3]%< ɢky1 ~EX"_l.L ^ !z l| M̐Rx(>8;uZREc_i ,}o"4̔ 0 #?q+GwX6ސ]c?H|=*a;ّĨ@ZH}AxR tQ De*@<Ɍ%T:o* Am6!4V(7E0)aXR=N{-O'n!9ANó43}:k "`0Nw航YB3SˑKSuW(R) spT|5T[g/Yi&^u<0˳Ma[ǜҶu;>9bӤgv= (.?0^mسpdI>F׬ϗNU,ߜošy/]KNzWW  ܦq4O)Xya묹ynj-ˤt|q9],^7',=Uq=ԐΟ;?->ҡLN1X&Y&._iHG$2cOo/P׃1VP?u^(7<ҁp@פ3\'vR`rp5P,rbnEL BkMPaM1xAu=Ň_&'EϢz/J>fgTSBCc+C#XMZ].1doi+75H2p9țwԝ#omdr tOk-J`QˆUZWFU?]i^)X\~UVl TDX4*ГUa,>"z̤3RЍ_`Ap&2.XEkJ)Vљ> At2׷+Iv{oܽ;IWͧtx~u?4}xA )'Hp5#)eidQPͻJvX,oap/ݴbR:.qڴEiֺ흡GQ;5Qi!,U2ø:V㹱4h wtoOͼu[mkƋC`jyܖ2r"/E4b۱`9 ` K۩2lm!-}|\cA-o"3 2CǞ,'NlmYV!xl\#!88S~L!mQ9W-= 0b~ C N9*>uv I} d]jm%I9V@ $$ ˆP)򧧢pґj}sf|QbcWq+Pu OFr_fs px b1s"-L8&]{`9Mğp=N/`H_oLhn1 .`ߩSwF+2Ǭ4 &3e7+Mrŷ1o /? x$#^鑹4^Ȏ<"9W#Y@tdm3̾#(_A/PKFyF-qQactivities/quiz_4/filters.xmlQ(K-*ϳU23PRHKOKU qӵPI)*RPK2R!BXĠByib0b;PKFyF|activities/quiz_4/module.xml]Rn0ʵS$z'/<{„>\xhXwFQ7(p>#ݱՍjvTPKFyFNn\49activities/quiz_4/calendar.xmlQ(K-*ϳU23PRHKOKU qӵPI-K+)2,PKFyF uHj#activities/quiz_4/grade_history.xmlQ(K-*ϳU23PRHKOKU qӵPI/JLI,./RPb>PKFyF8Tactivities/quiz_4/roles.xmlQ(K-*ϳU23PRHKOKU qӵP)I-RP:2SBXBřyy% QD!bvPKFyFm0h activities/quiz_4/grades.xmlu=S0{~CKfB!m0 ~IeJϮjᆬ(>Xj_֛mYb_Wh)sUwrQiCP B* pοk92K~gPKFyFq=} activities/quiz_4/inforef.xmlQ(K-*ϳU23PRHKOKU qӵPK/JMRPI/JLI,IͅAB)v&6@BU 2fpaijq ɉ%E11EJ,`PKFyFN37 files.xmlQ(K-*ϳU23PRHKOKU qӵPII-PKFyF uHjgrade_history.xmlQ(K-*ϳU23PRHKOKU qӵPI/JLI,./RPb>PKFyF`8' gradebook.xml;s | &EߌS8UUZfPK}չ,Xo>A*&}Q?U"(}sq@m'1!aG @YƗ )fX ˯Ƕ )궜feSZ}2NBm 0XwBU[F;E/ :7_܎]-|2i"zs^U`k`\&fw-sk{S#!>1Ja{k[,+qyƾLC%TѬ :몗T}1I\a;us̒8^,i?@5eXz3?DxK8Gzꩲ?/ha3XJXEaQ@ MbFj.>bDuPe,>)bݸD1i׎;݈&|Z){V$fmTT+ baj⺌7S3$\eѿoqpArm Fjn֙NgۈO,i`}*$Q%92ļs UbG/PKFyFv@V groups.xmlQ(K-*ϳU23PRHKOKU qӵPI//-(RP0>)PKFyFL/emoodle_backup.xmlWK8ϯ@9jQˆf9m.32HmpƘt@rv7\ջ\eү5 ZʛU+VޯfҚs /pܝOҦR鳢4"ָLew-^0چ]( M[QԻ)|)hC3 {[GYQ(=xi0L ?6o*&XBhv`Ch@*KZe,S4 M:yEYׄ%H@BKë`Hm.jˋ\fG)O f8#qwCBzrJ9ZQ 0(A$)!JHU0)Nޔ(y'Z6IG :L~35$r)>n+i\HRqHK,)7zu[)TRq畹 ?}δ$oB1لdG %xS ?UM)jUrl*i`kGRO4@oˆ<@5'ptx3O ~dC>?U|Rh' 8cBuSqӀzb\GAS].x?L=5 =+fS B%s!+̣)c2:_*D)r  2=x~; SA1K{Ƭ{c 3Y w| ? nxs<Ҩjkඥu~hcݱ}ՂU!&K^?&xIBPF6TO dЌzzwJo3=a{.8WKGns:PKFyF^yAS outcomes.xmlQ(K-*ϳU23PRHKOKU qӵP/-IM-OIM,cPKFyF\ questions.xmlWKo0WX9y@MK^ZҞPH81l_yT[R9yf25KhFtSC@Bdlgء+{/Sv#Em_20e\Ôu.XH 9@4 %]Q|LG' KT|Cu+lS]ft)&CR 1[+:{F|ϝ$K\x|?M$Z$Qz%C, [-8`0WUw5͓6 5llJC XY8!Si{f$cRW}W\)umX8o %rZXގkuNvc\o:C&Hk*G_]h/N;5S8{?EBXr U[QT:J")E_vz g 6] ?@<]?9_t? sJs$ơPKFyFLC>M roles.xmlQ(K-*ϳU23PRHKOKU qӵP)I-OIM,* cPKFyFq ?O scales.xmlQ(K-*ϳU23PRHKOKU qӵP)NNI-OIM,c PKEyFE-o2 moodle_backup.logUKN0s o׽!ma,L56TJh# {ŽplMZqnK6~,З%ā/6$A`x@cA8;jfB&[2b9(|n¥d(˓2#!A 1 CڿTE\.x-ScNRFcD_wɡfȄv[|x>Jb7!>QpP[-gR7fuR5GsN?2|`}5tVvgukZ~هkygesnhs>󹩎WJg^ *(&sUP/CKr-_|ϱDۓp!Vm?PKFyF activities/PKFyF)activities/quiz_4/PKFyFƮ7 Yactivities/quiz_4/quiz.xmlPKFyF-qQ}activities/quiz_4/filters.xmlPKFyF| activities/quiz_4/module.xmlPKFyFNn\49Yactivities/quiz_4/calendar.xmlPKFyF uHj#activities/quiz_4/grade_history.xmlPKFyF8TRactivities/quiz_4/roles.xmlPKFyFm0h activities/quiz_4/grades.xmlPKFyFq=} activities/quiz_4/inforef.xmlPKFyFN37 files.xmlPKFyF uHj grade_history.xmlPKFyF`8' r gradebook.xmlPKFyFv@V groups.xmlPKFyFL/e= moodle_backup.xmlPKFyF^yAS outcomes.xmlPKFyF\ <questions.xmlPKFyFLC>M ]roles.xmlPKFyFq ?O scales.xmlPKEyFE-o2 )moodle_backup.logPKPK9B\ ,tests/fixtures/moodle_42_random_question.mbznu[=i㶑:+XRn߈(MdM;֙MlWj].DBkx;fk>HJIԳED4@?ew_\LS%i_X雮o!a^be!#v46 Di^duZ1l%."XN^/oU/uqe $~yaٳ k:(d0zw&$LH=i@ ?Ӓ&D~Vk|s>h}Wk/5,qJQ-K+@"%egkԥzkWן*͵EL/V^}۷ow14}2NCڹ] N=vG8hx:ڸCA^7Ҧ0J__og7o/l<|i \ELJ0F~ucP< E:X.D q( \i%ƿj>V.VLIHC4Q@@̕5,&c\6wzww<#4!KFzu p],T,$Kj-:K|MdaNI$K!% p j4lEP(jWV,-N8Y%C'mт n$m3bO[2ъIaU" ! hǬ@mԄ"èc3" QRi^4( aS/gFTQy)aY!K9  PH~qWiEXDh#-6x hҘXNh*@mOfמ`TFovk\{\c6*~'҅.l_I.Ϙr oEXj1{(`?9s,:Ф\jOOT>6݆tc?ꢄ+>1Be 1187k oGSF\qx k3>Y,e.eOFumG8 4_8SZ8V٨ =^CY:q8\ #O۱F2* `b݁#(P6MAjXJp\e?})9c城 v/mvLvM32rKO/aO陽 (Q]GaظdmB~l=.~a]TFܫOe*VPCvo9jŽ (Q(hRWb8!Cz$OQK4z^P`&:]uJщC^Gw`I݁((Gk Mrݣ%(Twj>3"%½ڬd1Hh WRi=lepY[$ >7Oonfwl"3>UpHo5RXd92*8qDܰ;qg *+5"XLqV<9,2H n 5Jzd2",>,&D4uR26qThϫr%&J#Bm] C4|/Y|[X/1~Z+U*øZN !4JV-5),˥x6U*}P|(V'56Mֹh75Vr SX3a3NMW`S4K4*^#4~C\!jḈ߱q) r}rWo61lD)1NΓ(eH6U?nV"DsSX-1bEv.Js4Q\xbh@mEfOUTJ_iXM&B9|<6uvt3$DzR5M7qgǣi:uF /) J&An>s \$H1KFqF%+\V|~-]K cjZM$fb\PjUXmKXb(-nbqG4P*5x#Ίn%N$KlrF!.R(.Noxi~f~Mq 4X$ҏoޘ_͛n/7YH/נ9_ykklxe(DEY(`*2\-AHク!2t0bp7"ƴ`7;4D) KsD-&DeKQv 榮#0mT#O>kw8/̏d{2Z8A٨WՐ*j.#e˿ݔ8VQyz }$,Azܹ\h3+t`f_f|6XMߜ?O5>o䵫3‰ ]7;N^\;p~N[?4׷*G״48I?PUE+.0|i:^y 5kE l"_LJ0vĘ|d(0&^Ao8&Fo¿ymi97A|mwA| ^,3b?y,IP9^j]ls6NQO#>wiuT} ;WIwA &񊿂6,șa{m#/Z*3Jz6%J4&m#e?^ځrv7?ml }w:>!Z+vo(l79{9Peyt[V$?nHEҖ; ؛XIJ:|㡼;4vlkޙAy6( DMu3%U R6=4l0AJ&fcz= 6km< ,) mE3>SΦŤ$B{,󒌼S$T<|A͝<@ߢ+M7Z-,JIir$U=yu"!}OiJF%/>jY34+(0r7B Jm `>oGgYNe?i7WO#Yq0h]@fc}*% mb Y*};dD-j˟B ړsZ2%Hڗ YtmU"Sz[nH1lbzZ4uk6)q8lu}Y$>-/ȝAhPrBsQ+>`8xg<ƢSbHǰk}Ơ7i8m8U7Qq9B_J#_i70p7w[|c%yT}MA3"fE%HJḼ`I#}>ɗ W͜cxPd&n-;~0X_l}!cc @b:Fw~FȘ܊(w B#J3R]U w kИup̻~[6 .[#ݽ]gvr@w`InbNDZ#iuREwLA\= 8EȽl4&j V07M?L]?D\: _]4|mjdMX%qHfDh<(,J4v |W4*2,& pJk}6&>fx DRrLgFjĿRx+ZVqt!JX%%MNrdPD v'ޘJ"MQ_\tEuVUTC''˸: Od{c%wGMޯ= *{H@Ǐ8\,7P.^{d< o s8vt=Ssq_#%2kU5\Vn3 ܔubLmcCg <9S`nΧJæȒ^٦\cljz:s&tZ`, ^!ȳgȞX.ڛٶ=`6׶76FaPK9B\O**0tests/fixtures/pre-40-shared-random-question.mbznu[]K䶑̯a7 VJcw%֗  VS]EX3/u؃/:֡xG OG"H$W}0vؿvol /]Jp._/Χ<8./:=ei/Ts<ص,¶ ]XKUH~1[r<%Yk ^4̢$}oMD$MrJKӖħn\-|&'6vS6-zRJ6a]vLiXZ,:9w$554ip k`]-,vÏّՒg0Ks>OȷղNj[_;" bR:iH(H2 IlZ8;| DF&)p>dY'Wd\ m}pT?rJ $jY* ˎyvq>ET"^tΖ&z,>Y?)l7_.Y|O{rO,X֣5=n5qI1DŽD N9g@}4Nr$ \!!(wd}$ΒTI˯Y_uִH4#y>'n,ikuE$eBU@#M_ű纴rz@ULW5}QnmYN**$K 4RjZJw'*>M>> 7T-iJgˁl`@bxo mJӧ;oZ45ɋMu`Qw\Ha'$?Ï7ىv$& O !C{&;.?׬oMo" 6;1P柮nJH,9l_~{:S찥INx$46~qO)wK?ϞCoP+I~,ˁnjegn'j U}}=)3fJ<8-Q3`jIufPcY\vkMԼf^z>lɑ:؆ԉAѼt&Ir&-Ku\'i~KɃ]P~aUJӂwc9Cvnt/տ@7 p?if/?$tlW cj7/n_n~WTN(]*ϳ2\-n؄;^8{d3 -!%|61ϕ~+6{altzF0;O wȽBf')U@olzOdCWMr- !za2!"P mZr#ቬѕue*2]w˧ՠ { س<߭yzQߔ|rr>1[>p&NTfRBjbɳ ]97mPurjSjrw,__岹d.gkqs[hoւ#{V7q$ۋoҹ==9Pɳ$ ?Έ}?m|Fp>eQ|gbU5y lӡ5U)=G$m; !C;S#ɾ*P#a6BFVFC"vQ$bVܘդJӲ,DPt6uC-~mX[4pKN'Ix޿ղ~!`̈́ʪPβ=b׽ū ʹ"QD{vc_$T'] x)]tKsr_p7g~N,l<[`j?rA7 (aNf?m^2ޛA5dvB{ 8?{lR$*[-Xd9x]e-R7%ކ,^ e!ox|Y8VM}jԸReuR/AS>sj5cjvPxmyHfZ`~lR𔆫%SZ@%Xr1i1cFMtJv)mpZgͣrs3 ã6vCy,.#nE+ɷLfc6zwǀn]S]|ɺ0n1Gwwη=_o Be(ާFS6:桨jlS6{Be3yXKS[Holwzv6Ն \_;kem#=X_ֳkqpB-/S;bOwπ??E}ax<c ,xTx`ay%% c/8BVI$\j=9ZU"@ǗT?:T`ʃʲEOd? "1At Nr'?l}VϿqN5<&I~o~ņNfXOumԺ>OcζmU?x:ZDOݰG?Im\d?(/ģʗe㍝U9K'˪|?c3KB1Qnղ~fy_D?@3(/ă/F|Z=iERqkUieʳ>yBWOXQ?y?ƣ:x9?(☔l˞ya#U= *Dly s,E kڻ *[~##rd!ĔT [ajJ ,j)rf5!O32|r~jO س\gg@ͱ缚cO׬!a;6P=}G{1Zk8 (dҒ6xxe92Z+}E0^FaNYjcOC)%$d<,!<%dױT#2Q؈L4F[BWBPBX[B RC"P_BN[BL_B$vR-L=fa\M ", Ե04 SaԶ.-A1FG Ȧu xA) , 20(`Q2kef>12 3q)hOOAgOU9>¶Z!Z |s0CN;ZcR,Wf4b@m-|o_6ȻHחAm_Щe|t}e+}NaEeFQTFk420(*ZFιx֪akW[7 tCK*|ƱV: G< ;}XqTpv*?Tp "'Sn#q L) k{Hqu@OmA] 0}s^k m2<`킧v¶8@m>0mI@=QЦ0M`yQǘPP9/j"-9/5GԳ'VNar%*3 6`tŵ%5@+VQٕ L6)KqTb'BtB> ;Tؒ |=BLіIh1E#˷uTςg{٪w˵QpQ 80$v KcK{Y>tzo} <@6Įk{DzmY_l|ڑXVCRJ*\r"EHEHjvg—\Tn*WAڙ-@I'x&ulwQHBϝ/<6!pFgh<_X#k؞(3ųQ0aPb X [L=B8!qhh߹84 BDF }td+?4>ƑGaG-%E\ g -@#B'""88t(-֘= `u2H V vsL]hC/̮`ʞZZݹ>_`VD(1&B3$^4ehgp1;a,? &TaM97 zt!х_H#s,%cdyW9Wbݕ'[Y&s m7Ed: jep5Pe6uU~{$?_OЮMZfuVSѫt][ ❊)@kWPcYҸ'K"t_e>_wO8>^[~ҡD;tqN׭{!wk$[E3(ٞayGsox2u9x;N|[ej 2~0Ao-Sna8 &FDB<(8DH": tc^!6,YfAi&2 F7:WuhR܌q*Ms:Ƶʽ]4ɢP;E6?oeFi*BuErM STk&Њ^f0VD զY$n5[,#)*EZEyGI6)YQQ)8IIE3Sp,X.s6袡FH`.ͽf=weRePzX (.6I2u )Q."2Fd;˶zR#xMe)QD ,^ JF?)v\߱|҂ŋ{hq NČXΐUВ$5j:=p?81#8a !b.-9"2y0&ylҒ()ē̥&95a˝[h^k'gQotR7ֵ{4qJMduʿO+[9!&mtr$kkث='䟸uc?+/q\ J~hyoiK${e=޴~4 x:KWOSa f ~|,w#Dri8wi~~ C%0X֫xAߕԉZ$1F $1ބq,t5 E @aOCOjvPոwz}GƏƿfdl̊F Z]5&t6zk 򒑏6a*}=\Ri ')vXU)Tw qu"NO,hޘ.)̞)P\)e_b˽5v]>+#R?w(] wXcr+]ˠ`l*Ѫ/[˲uK92y-hMH3p;`c/nI$YjD4^k42'd/к$+H^3&G~,j :kF,3&HKZq\nGZL]p ~)3Q{Z'{?ȱ p v>A4LdC/"tj>Ga,,W6fA4FmiER1,g;r"<XC<%@%FRفjZ: ZSUxH3$̫P;o)$Av1}~Ǵ $$.bм&ɚbde WI㿼n 81;1?1>G|+o ! ?a MJn "ޛ|>~Tz#8-lO>ЧtlmTe|m`g69'YA9ԇH`$^ǥ."7D[zrlaچ@0tj9@eZ MNB- !}mYTa|5U"A ج$̗] JXoQraBo#5-"4AyM7Y\6Bq7sKSHT9Mvt"UBPr&RZ⌧TٚK!A$^s9 ^lIS~n(gAZH doFJo%? %}յⰨ[^mhȶXop su\q'XnQrvBFHT!yX*i)]GWޏf GiqE{_ ߮[!Mb-}[[ui.mեo9|{Oww{3%1ȞZnCa }q!]RAOzS{vv65n2pp_ n8(W n8(bj _7ض/G_Bt^c}젏Wٚ?Ń /oAp;8/JM!ӓf_?)KןV40cjD&WZ^i3[4ۙA{ 1j^G qWuØ`;.@_aȵ`\^.v?t<(_=܊Cgdd&`8YHkGE pXo!\W7sB1;?oN3Ì7'ۜ/#!ovď0!WxOo>YNby}ףm0]rϷ5j ,z6q-'B3?XX$>?3]?A?%-zׯVBof`,pZ"߰m8W%Ft0]׮ `<]۾pB]E,8ܖCӸmJP9$x9NBGtS1K<4 0yMX%jMti:e{IˑwMts/]K\'6I/n4K^T $Rwl;&l__h;/<]wsw  bW+?!>ЛI/!t\2F}E HͷZ3! D̶lj RAU*C(͋͂9VtpNilMCG]6.r|qc;\JMTon`\#yAvl3 \CIMyhy&?  4hРA 4hРA 4hРA 4hРA 4hРAo ~PK9B\]Yyy&tests/backup/repeated_restore_test.phpnu[. namespace mod_quiz\backup; use advanced_testcase; use backup_controller; use restore_controller; use quiz_question_helper_test_trait; use backup; 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 . '/question/engine/lib.php'); require_once($CFG->dirroot . '/mod/quiz/locallib.php'); require_once($CFG->dirroot . '/course/lib.php'); require_once($CFG->dirroot . '/mod/quiz/tests/quiz_question_helper_test_trait.php'); /** * Test repeatedly restoring a quiz into another course. * * @package mod_quiz * @category test * @copyright Julien Rädler * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @covers \restore_questions_parser_processor * @covers \restore_create_categories_and_questions */ final class repeated_restore_test extends advanced_testcase { use quiz_question_helper_test_trait; /** * Create 2 courses, and a quiz with questions on the first course. * * @param bool $sharedquestions If true, create the questions in a qbank module rather than the quiz itself. * @return array */ protected function create_courses_and_quiz(bool $sharedquestions = false): array { global $USER; // Create two courses and a user with editing teacher capabilities. $generator = $this->getDataGenerator(); $course1 = $generator->create_course(); $course2 = $generator->create_course(); $teacher = $USER; $generator->enrol_user($teacher->id, $course1->id, 'editingteacher'); $generator->enrol_user($teacher->id, $course2->id, 'editingteacher'); // Create a quiz with questions in the first course. $quiz = $this->create_test_quiz($course1); if ($sharedquestions) { $qbank = $generator->get_plugin_generator('mod_qbank')->create_instance(['course' => $course1->id]); $context = \context_module::instance($qbank->cmid); } else { $context = \context_module::instance($quiz->cmid); } $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); // Create a question category. $cat = $questiongenerator->create_question_category(['contextid' => $context->id]); // Create a short answer question. $saq = $questiongenerator->create_question('shortanswer', null, ['category' => $cat->id]); // Update the question to simulate editing. $questiongenerator->update_question($saq); // Add question to quiz. quiz_add_quiz_question($saq->id, $quiz); // Create a numerical question. $numq = $questiongenerator->create_question('numerical', null, ['category' => $cat->id]); // Update the question to simulate multiple versions. $questiongenerator->update_question($numq); $questiongenerator->update_question($numq); // Add question to quiz. quiz_add_quiz_question($numq->id, $quiz); // Create a true false question. $tfq = $questiongenerator->create_question('truefalse', null, ['category' => $cat->id]); // Update the question to simulate multiple versions. $questiongenerator->update_question($tfq); $questiongenerator->update_question($tfq); // Add question to quiz. quiz_add_quiz_question($tfq->id, $quiz); // Capture original question IDs for verification after import. $modules1 = get_fast_modinfo($course1->id)->get_instances_of('quiz'); $module1 = reset($modules1); $questionscourse1 = \mod_quiz\question\bank\qbank_helper::get_question_structure( $module1->instance, $module1->context, ); $originalquestionids = []; foreach ($questionscourse1 as $slot) { array_push($originalquestionids, intval($slot->questionid)); } return [ $course1, $course2, $quiz, $teacher, $originalquestionids, ]; } /** * Restore a quiz using private questions twice into the same target course, * and verify the quiz uses a newly-restored copy of the questions each time. */ public function test_restore_quiz_with_own_questions_into_other_course_twice(): void { $this->resetAfterTest(); $this->setAdminUser(); [, $course2, $quiz, $teacher, $originalquestionids] = $this->create_courses_and_quiz(); // Backup the quiz course. $bc = new backup_controller( backup::TYPE_1ACTIVITY, $quiz->cmid, backup::FORMAT_MOODLE, backup::INTERACTIVE_NO, backup::MODE_IMPORT, $teacher->id, ); $backupid = $bc->get_backupid(); $bc->execute_plan(); $bc->destroy(); // Import the backup into the second course. $rc = new restore_controller( $backupid, $course2->id, backup::INTERACTIVE_NO, backup::MODE_IMPORT, $teacher->id, backup::TARGET_CURRENT_ADDING, ); $rc->execute_precheck(); $rc->execute_plan(); $rc->destroy(); // Verify the question ids from the quiz in the original course are different // from the question ids in the duplicated quiz in the second course. $modules2 = get_fast_modinfo($course2->id)->get_instances_of('quiz'); $module2 = reset($modules2); $questionscourse2firstimport = \mod_quiz\question\bank\qbank_helper::get_question_structure( $module2->instance, $module2->context, ); foreach ($questionscourse2firstimport as $slot) { $this->assertNotContains( intval($slot->questionid), $originalquestionids, "Question ID $slot->questionid should not be in the original course's question IDs.", ); } // Repeat the backup and import process to simulate a second import. $bc = new backup_controller( backup::TYPE_1ACTIVITY, $quiz->cmid, backup::FORMAT_MOODLE, backup::INTERACTIVE_NO, backup::MODE_IMPORT, $teacher->id, ); $backupid = $bc->get_backupid(); $bc->execute_plan(); $bc->destroy(); $rc = new restore_controller( $backupid, $course2->id, backup::INTERACTIVE_NO, backup::MODE_IMPORT, $teacher->id, backup::TARGET_CURRENT_ADDING, ); $rc->execute_precheck(); $rc->execute_plan(); $rc->destroy(); // Verify that the quiz in the second restore uses a third copy of the questions. $modules3 = get_fast_modinfo($course2->id)->get_instances_of('quiz'); $module3 = end($modules3); $questionscourse2secondimport = \mod_quiz\question\bank\qbank_helper::get_question_structure( $module3->instance, $module3->context, ); foreach ($questionscourse2secondimport as $slot) { $this->assertNotEquals($questionscourse2firstimport[$slot->slot]->questionid, $slot->questionid); $this->assertNotContains( intval($slot->questionid), $originalquestionids, "Question ID $slot->questionid should not be in the original course's question IDs.", ); } } /** * Restore a quiz using shared questions twice into the same target course, * and verify the quiz uses the original questions each time. */ public function test_restore_quiz_with_shared_questions_into_other_course_twice(): void { $this->resetAfterTest(); $this->setAdminUser(); [, $course2, $quiz, $teacher, $originalquestionids] = $this->create_courses_and_quiz(true); // Backup the quiz. $bc = new backup_controller( backup::TYPE_1ACTIVITY, $quiz->cmid, backup::FORMAT_MOODLE, backup::INTERACTIVE_NO, backup::MODE_IMPORT, $teacher->id, ); $backupid = $bc->get_backupid(); $bc->execute_plan(); $bc->destroy(); // Import the backup into the second course. $rc = new restore_controller( $backupid, $course2->id, backup::INTERACTIVE_NO, backup::MODE_IMPORT, $teacher->id, backup::TARGET_CURRENT_ADDING, ); $rc->execute_precheck(); $rc->execute_plan(); $rc->destroy(); // Verify the question ids from the quiz in the original course are the same as // the question ids in the duplicated quiz in the second course. $modules2 = get_fast_modinfo($course2->id)->get_instances_of('quiz'); $module2 = reset($modules2); $questionscourse2firstimport = \mod_quiz\question\bank\qbank_helper::get_question_structure( $module2->instance, $module2->context, ); foreach ($questionscourse2firstimport as $slot) { $this->assertContains( intval($slot->questionid), $originalquestionids, "Question ID $slot->questionid should be in the original course's question IDs.", ); } // Repeat the backup and import process to simulate a second import. $bc = new backup_controller( backup::TYPE_1ACTIVITY, $quiz->cmid, backup::FORMAT_MOODLE, backup::INTERACTIVE_NO, backup::MODE_IMPORT, $teacher->id, ); $backupid = $bc->get_backupid(); $bc->execute_plan(); $bc->destroy(); $rc = new restore_controller( $backupid, $course2->id, backup::INTERACTIVE_NO, backup::MODE_IMPORT, $teacher->id, backup::TARGET_CURRENT_ADDING, ); $rc->execute_precheck(); $rc->execute_plan(); $rc->destroy(); // Verify that the quiz in the second restore uses the same questions from the original quiz and the first restore. $modules3 = get_fast_modinfo($course2->id)->get_instances_of('quiz'); $module3 = end($modules3); $questionscourse2secondimport = \mod_quiz\question\bank\qbank_helper::get_question_structure( $module3->instance, $module3->context, ); foreach ($questionscourse2secondimport as $slot) { $this->assertEquals($questionscourse2firstimport[$slot->slot]->questionid, $slot->questionid); $this->assertContains( intval($slot->questionid), $originalquestionids, "Question ID $slot->questionid should be in the original course's question IDs.", ); } } /** * Restore a quiz using shared questions twice into the same target course with the qbank, * and verify the quiz uses a newly-restored copy of the questions each time. */ public function test_restore_quiz_with_qbank_into_other_course_twice(): void { global $DB; $this->resetAfterTest(); $this->setAdminUser(); [$course1, $course2, , $teacher, $originalquestionids] = $this->create_courses_and_quiz(true); // Backup the first course. $bc = new backup_controller( backup::TYPE_1COURSE, $course1->id, backup::FORMAT_MOODLE, backup::INTERACTIVE_NO, backup::MODE_IMPORT, $teacher->id, ); $backupid = $bc->get_backupid(); $bc->execute_plan(); $bc->destroy(); // Import the backup into the second course. $rc = new restore_controller( $backupid, $course2->id, backup::INTERACTIVE_NO, backup::MODE_IMPORT, $teacher->id, backup::TARGET_CURRENT_ADDING, ); $rc->execute_precheck(); $rc->execute_plan(); $rc->destroy(); // Verify the question ids from the quiz in the original course are different // from the question ids in the duplicated quiz in the second course. $modules2 = get_fast_modinfo($course2->id)->get_instances_of('quiz'); $module2 = reset($modules2); $questionscourse2firstimport = \mod_quiz\question\bank\qbank_helper::get_question_structure( $module2->instance, $module2->context, ); foreach ($questionscourse2firstimport as $slot) { $this->assertNotContains( intval($slot->questionid), $originalquestionids, "Question ID $slot->questionid should not be in the original course's question IDs.", ); } // Repeat the backup and import process to simulate a second import. $bc = new backup_controller( backup::TYPE_1COURSE, $course1->id, backup::FORMAT_MOODLE, backup::INTERACTIVE_NO, backup::MODE_IMPORT, $teacher->id, ); $backupid = $bc->get_backupid(); $bc->execute_plan(); $bc->destroy(); $rc = new restore_controller( $backupid, $course2->id, backup::INTERACTIVE_NO, backup::MODE_IMPORT, $teacher->id, backup::TARGET_CURRENT_ADDING, ); $rc->execute_precheck(); $rc->execute_plan(); $rc->destroy(); // Verify that the quiz in the second restore uses a third copy of the questions. $modules3 = get_fast_modinfo($course2->id)->get_instances_of('quiz'); $module3 = end($modules3); $questionscourse2secondimport = \mod_quiz\question\bank\qbank_helper::get_question_structure( $module3->instance, $module3->context, ); foreach ($questionscourse2secondimport as $slot) { $this->assertNotEquals($questionscourse2firstimport[$slot->slot]->questionid, $slot->questionid); $this->assertNotContains( intval($slot->questionid), $originalquestionids, "Question ID $slot->questionid should not be in the original course's question IDs.", ); } } /** * Import the quiz and qbank from course 1 to course 2, then import just the quiz a second time. * * As the user can use questions from the original qbank, the second quiz will use the original questions. */ public function test_restore_quiz_with_shared_questions_then_just_quiz(): void { $this->resetAfterTest(); $this->setAdminUser(); [$course1, $course2, $quiz, $teacher, $originalquestionids] = $this->create_courses_and_quiz(true); // Backup the first course. $bc = new backup_controller( backup::TYPE_1COURSE, $course1->id, backup::FORMAT_MOODLE, backup::INTERACTIVE_NO, backup::MODE_IMPORT, $teacher->id, ); $backupid = $bc->get_backupid(); $bc->execute_plan(); $bc->destroy(); // Import the backup into the second course. $rc = new restore_controller( $backupid, $course2->id, backup::INTERACTIVE_NO, backup::MODE_IMPORT, $teacher->id, backup::TARGET_CURRENT_ADDING, ); $rc->execute_precheck(); $rc->execute_plan(); $rc->destroy(); // Verify the question ids from the quiz in the original course are different // from the question ids in the duplicated quiz in the second course. $modules2 = get_fast_modinfo($course2->id)->get_instances_of('quiz'); $module2 = reset($modules2); $questionscourse2firstimport = \mod_quiz\question\bank\qbank_helper::get_question_structure( $module2->instance, $module2->context, ); foreach ($questionscourse2firstimport as $slot) { $this->assertNotContains( intval($slot->questionid), $originalquestionids, "Question ID $slot->questionid should not be in the original course's question IDs.", ); } // Repeat the backup and import process with just the quiz. $bc = new backup_controller( backup::TYPE_1ACTIVITY, $quiz->cmid, backup::FORMAT_MOODLE, backup::INTERACTIVE_NO, backup::MODE_IMPORT, $teacher->id, ); $backupid = $bc->get_backupid(); $bc->execute_plan(); $bc->destroy(); $rc = new restore_controller( $backupid, $course2->id, backup::INTERACTIVE_NO, backup::MODE_IMPORT, $teacher->id, backup::TARGET_CURRENT_ADDING, ); $rc->execute_precheck(); $rc->execute_plan(); $rc->destroy(); // Verify that the quiz in the second restore uses the questions from the original course. $modules3 = get_fast_modinfo($course2->id)->get_instances_of('quiz'); $module3 = end($modules3); $questionscourse2secondimport = \mod_quiz\question\bank\qbank_helper::get_question_structure( $module3->instance, $module3->context, ); foreach ($questionscourse2secondimport as $slot) { $this->assertNotEquals($questionscourse2firstimport[$slot->slot]->questionid, $slot->questionid); $this->assertContains( intval($slot->questionid), $originalquestionids, "Question ID $slot->questionid should be in the original course's question IDs.", ); } } /** * Restore a copy of a quiz to the same course, using questions that include line breaks in the text. */ public function test_restore_question_with_linebreaks(): void { global $USER; $this->resetAfterTest(); $this->setAdminUser(); // Step 1: Create two courses and a user with editing teacher capabilities. $generator = $this->getDataGenerator(); $course1 = $generator->create_course(); $course2 = $generator->create_course(); $teacher = $USER; $generator->enrol_user($teacher->id, $course1->id, 'editingteacher'); $generator->enrol_user($teacher->id, $course2->id, 'editingteacher'); // Create a quiz with questions in the first course. $quiz = $this->create_test_quiz($course1); $qbank = $generator->get_plugin_generator('mod_qbank')->create_instance(['course' => $course1->id]); $context = \context_module::instance($qbank->cmid); $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); // Create a question category. $cat = $questiongenerator->create_question_category(['contextid' => $context->id]); // Create questions and add to the quiz. $q1 = $questiongenerator->create_question('truefalse', null, [ 'category' => $cat->id, 'questiontext' => ['text' => "

                Question

                \r\n

                One

                ", 'format' => FORMAT_MOODLE] ]); $q2 = $questiongenerator->create_question('truefalse', null, [ 'category' => $cat->id, 'questiontext' => ['text' => "

                Question

                \n

                Two

                ", 'format' => FORMAT_MOODLE] ]); // Add question to quiz. quiz_add_quiz_question($q1->id, $quiz); quiz_add_quiz_question($q2->id, $quiz); // Capture original question IDs for verification after import. $modules1 = get_fast_modinfo($course1->id)->get_instances_of('quiz'); $module1 = reset($modules1); $originalslots = \mod_quiz\question\bank\qbank_helper::get_question_structure( $module1->instance, $module1->context); $originalquestionids = []; foreach ($originalslots as $slot) { array_push($originalquestionids, intval($slot->questionid)); } $this->assertCount(2, get_questions_category($cat, false)); // Step 2: Backup the quiz $bc = new backup_controller(backup::TYPE_1ACTIVITY, $quiz->cmid, backup::FORMAT_MOODLE, backup::INTERACTIVE_NO, backup::MODE_IMPORT, $teacher->id); $backupid = $bc->get_backupid(); $bc->execute_plan(); $bc->destroy(); // Step 3: Import the backup into the same course. $rc = new restore_controller($backupid, $course1->id, backup::INTERACTIVE_NO, backup::MODE_IMPORT, $teacher->id, backup::TARGET_CURRENT_ADDING); $rc->execute_precheck(); $rc->execute_plan(); $rc->destroy(); // Verify the question ids from the new quiz match the first. $modules2 = get_fast_modinfo($course1->id)->get_instances_of('quiz'); $this->assertCount(2, $modules2); $module2 = end($modules2); $copyslots = \mod_quiz\question\bank\qbank_helper::get_question_structure( $module2->instance, $module2->context); foreach ($copyslots as $slot) { $this->assertContains(intval($slot->questionid), $originalquestionids); } // The category should still only contain 2 question, neither question should be duplicated. $this->assertCount(2, get_questions_category($cat, false)); } /** * Return a list of qtypes with valid generators in their helper class. * * This will check all installed qtypes for a test helper class, then find a defined test question which has a corresponding * form_data method and return it. If the helper doesn't have a form_data method for any test question, it will return a * null test question name for that qtype. * * @return array */ public static function get_qtype_generators(): array { global $CFG; $generators = []; foreach (\core\plugin_manager::instance()->get_plugins_of_type('qtype') as $qtype) { if ($qtype->name == 'random') { continue; } $helperpath = "{$CFG->dirroot}/question/type/{$qtype->name}/tests/helper.php"; if (!file_exists($helperpath)) { continue; } require_once($helperpath); $helperclass = "qtype_{$qtype->name}_test_helper"; if (!class_exists($helperclass)) { continue; } $helper = new $helperclass(); $testquestion = null; foreach ($helper->get_test_questions() as $question) { if (method_exists($helper, "get_{$qtype->name}_question_form_data_{$question}")) { $testquestion = $question; break; } } $generators[$qtype->name] = [ 'qtype' => $qtype->name, 'testquestion' => $testquestion, ]; } return $generators; } /** * Restore a quiz with questions of same stamp into the same course, but different answers. * * @dataProvider get_qtype_generators * @param string $qtype The name of the qtype plugin to test * @param ?string $testquestion The test question to generate for the plugin. If null, the plugin will be skipped * with a message. */ public function test_restore_quiz_with_same_stamp_questions(string $qtype, ?string $testquestion): void { global $DB, $USER; if (is_null($testquestion)) { $this->markTestSkipped( "Cannot test qtype_{$qtype} as there is no test question with a form_data method in the " . "test helper class." ); } $this->resetAfterTest(); $this->setAdminUser(); // Create a course and a user with editing teacher capabilities. $generator = $this->getDataGenerator(); $course1 = $generator->create_course(); $teacher = $USER; $generator->enrol_user($teacher->id, $course1->id, 'editingteacher'); $qbank = $generator->get_plugin_generator('mod_qbank')->create_instance(['course' => $course1->id]); $context = \context_module::instance($qbank->cmid); $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); // Create a question category. $cat = $questiongenerator->create_question_category(['contextid' => $context->id]); // Create 2 quizzes with 2 questions multichoice. $quiz1 = $this->create_test_quiz($course1); $question1 = $questiongenerator->create_question($qtype, $testquestion, ['category' => $cat->id]); quiz_add_quiz_question($question1->id, $quiz1, 0); $question2 = $questiongenerator->create_question($qtype, $testquestion, ['category' => $cat->id]); quiz_add_quiz_question($question2->id, $quiz1, 0); // Update question2 to have the same stamp as question1. $DB->set_field('question', 'stamp', $question1->stamp, ['id' => $question2->id]); // Change the answers of the question2 to be different to question1. $question2data = \question_bank::load_question_data($question2->id); if (!isset($question2data->options->answers) || empty($question2data->options->answers)) { $this->markTestSkipped( "Cannot test edited answers for qtype_{$qtype} as it does not use answers.", ); } foreach ($question2data->options->answers as $answer) { $DB->set_field('question_answers', 'answer', 'edited', ['id' => $answer->id]); } // Backup quiz1. $bc = new backup_controller(backup::TYPE_1ACTIVITY, $quiz1->cmid, backup::FORMAT_MOODLE, backup::INTERACTIVE_NO, backup::MODE_IMPORT, $teacher->id); $backupid = $bc->get_backupid(); $bc->execute_plan(); $bc->destroy(); // Restore the backup into the same course. $rc = new restore_controller($backupid, $course1->id, backup::INTERACTIVE_NO, backup::MODE_IMPORT, $teacher->id, backup::TARGET_CURRENT_ADDING); $rc->execute_precheck(); $rc->execute_plan(); $rc->destroy(); // Verify that the newly-restored quiz uses the same question as quiz2. $modules = get_fast_modinfo($course1->id)->get_instances_of('quiz'); $this->assertCount(2, $modules); $quiz2structure = \mod_quiz\question\bank\qbank_helper::get_question_structure( $quiz1->id, \context_module::instance($quiz1->cmid), ); $quiz2 = end($modules); $quiz2structure = \mod_quiz\question\bank\qbank_helper::get_question_structure($quiz2->instance, $quiz2->context); $this->assertEquals($quiz2structure[1]->questionid, $quiz2structure[1]->questionid); $this->assertEquals($quiz2structure[2]->questionid, $quiz2structure[2]->questionid); } /** * Restore a quiz with duplicate questions (same stamp and questions) into the same course. * * This is a contrived case, but this test serves as a control for the other tests in this class, proving that the hashing * process will match an identical question. * * @dataProvider get_qtype_generators * @param string $qtype The name of the qtype plugin to test * @param ?string $testquestion The test question to generate for the plugin. If null, the plugin will be skipped * with a message. */ public function test_restore_quiz_with_duplicate_questions(string $qtype, ?string $testquestion): void { global $DB, $USER; if (is_null($testquestion)) { $this->markTestSkipped( "Cannot test qtype_{$qtype} as there is no test question with a form_data method in the " . "test helper class." ); } $this->resetAfterTest(); $this->setAdminUser(); // Create a course and a user with editing teacher capabilities. $generator = $this->getDataGenerator(); $course1 = $generator->create_course(); $teacher = $USER; $generator->enrol_user($teacher->id, $course1->id, 'editingteacher'); $qbank = $generator->get_plugin_generator('mod_qbank')->create_instance(['course' => $course1->id]); $context = \context_module::instance($qbank->cmid); $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); // Create a question category. $cat = $questiongenerator->create_question_category(['contextid' => $context->id]); // Create a quiz with 2 identical but separate questions. $quiz1 = $this->create_test_quiz($course1); $question1 = $questiongenerator->create_question($qtype, $testquestion, ['category' => $cat->id]); quiz_add_quiz_question($question1->id, $quiz1, 0); $question2 = $questiongenerator->create_question($qtype, $testquestion, ['category' => $cat->id]); quiz_add_quiz_question($question2->id, $quiz1, 0); // Update question2 to have the same times and stamp as question1. $DB->update_record('question', [ 'id' => $question2->id, 'stamp' => $question1->stamp, 'timecreated' => $question1->timecreated, 'timemodified' => $question1->timemodified, ]); // Backup quiz. $bc = new backup_controller(backup::TYPE_1ACTIVITY, $quiz1->cmid, backup::FORMAT_MOODLE, backup::INTERACTIVE_NO, backup::MODE_IMPORT, $teacher->id); $backupid = $bc->get_backupid(); $bc->execute_plan(); $bc->destroy(); // Restore the backup into the same course. $rc = new restore_controller($backupid, $course1->id, backup::INTERACTIVE_NO, backup::MODE_IMPORT, $teacher->id, backup::TARGET_CURRENT_ADDING); $rc->execute_precheck(); $rc->execute_plan(); $rc->destroy(); // Expect that the restored quiz will have the second question in both its slots // by virtue of identical stamp, version, and hash of question answer texts. $modules = get_fast_modinfo($course1->id)->get_instances_of('quiz'); $this->assertCount(2, $modules); $quiz2 = end($modules); $quiz2structure = \mod_quiz\question\bank\qbank_helper::get_question_structure($quiz2->instance, $quiz2->context); $this->assertEquals($quiz2structure[1]->questionid, $quiz2structure[2]->questionid); } /** * Restore a quiz with questions that have the same stamp but different text. * * @dataProvider get_qtype_generators * @param string $qtype The name of the qtype plugin to test * @param ?string $testquestion The test question to generate for the plugin. If null, the plugin will be skipped * with a message. */ public function test_restore_quiz_with_edited_questions(string $qtype, ?string $testquestion): void { global $DB, $USER; if (is_null($testquestion)) { $this->markTestSkipped( "Cannot test qtype_{$qtype} as there is no test question with a form_data method in the " . "test helper class." ); } $this->resetAfterTest(); $this->setAdminUser(); // Create a course and a user with editing teacher capabilities. $generator = $this->getDataGenerator(); $course1 = $generator->create_course(); $teacher = $USER; $generator->enrol_user($teacher->id, $course1->id, 'editingteacher'); $qbank = $generator->get_plugin_generator('mod_qbank')->create_instance(['course' => $course1->id]); $context = \context_module::instance($qbank->cmid); $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); // Create a question category. $cat = $questiongenerator->create_question_category(['contextid' => $context->id]); // Create a quiz with 2 identical but separate questions. $quiz1 = $this->create_test_quiz($course1); $question1 = $questiongenerator->create_question($qtype, $testquestion, ['category' => $cat->id]); quiz_add_quiz_question($question1->id, $quiz1); $question2 = $questiongenerator->create_question($qtype, $testquestion, ['category' => $cat->id]); // Edit question 2 to have the same stamp and times as question1, but different text. $DB->update_record('question', [ 'id' => $question2->id, 'questiontext' => 'edited', 'stamp' => $question1->stamp, 'timecreated' => $question1->timecreated, 'timemodified' => $question1->timemodified, ]); quiz_add_quiz_question($question2->id, $quiz1); // Backup quiz. $bc = new backup_controller(backup::TYPE_1ACTIVITY, $quiz1->cmid, backup::FORMAT_MOODLE, backup::INTERACTIVE_NO, backup::MODE_IMPORT, $teacher->id); $backupid = $bc->get_backupid(); $bc->execute_plan(); $bc->destroy(); // Restore the backup into the same course. $rc = new restore_controller($backupid, $course1->id, backup::INTERACTIVE_NO, backup::MODE_IMPORT, $teacher->id, backup::TARGET_CURRENT_ADDING); $rc->execute_precheck(); $rc->execute_plan(); $rc->destroy(); // The quiz should contain both questions, as they have different text. $modules = get_fast_modinfo($course1->id)->get_instances_of('quiz'); $this->assertCount(2, $modules); $quiz2 = end($modules); $quiz2structure = \mod_quiz\question\bank\qbank_helper::get_question_structure($quiz2->instance, $quiz2->context); $this->assertEquals($quiz2structure[1]->questionid, $question1->id); $this->assertEquals($quiz2structure[2]->questionid, $question2->id); } /** * Restore a course to another course having questions with the same stamp in a shared question bank context category. * * @dataProvider get_qtype_generators * @param string $qtype The name of the qtype plugin to test * @param ?string $testquestion The test question to generate for the plugin. If null, the plugin will be skipped * with a message. */ public function test_restore_course_with_same_stamp_questions(string $qtype, ?string $testquestion): void { global $DB, $USER; if (is_null($testquestion)) { $this->markTestSkipped( "Cannot test qtype_{$qtype} as there is no test question with a form_data method in the " . "test helper class." ); } $this->resetAfterTest(); $this->setAdminUser(); // Create two courses and a user with editing teacher capabilities. $generator = $this->getDataGenerator(); $course1 = $generator->create_course(); $course2 = $generator->create_course(); $qbank = $generator->get_plugin_generator('mod_qbank')->create_instance(['course' => $course2->id]); $teacher = $USER; $generator->enrol_user($teacher->id, $course1->id, 'editingteacher'); $generator->enrol_user($teacher->id, $course2->id, 'editingteacher'); $context = \context_module::instance($qbank->cmid); $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); // Create a question category. $cat = $questiongenerator->create_question_category(['contextid' => $context->id]); // Create quiz with question. $quiz1 = $this->create_test_quiz($course1); $question1 = $questiongenerator->create_question($qtype, $testquestion, ['category' => $cat->id]); quiz_add_quiz_question($question1->id, $quiz1, 0); $quiz2 = $this->create_test_quiz($course1); $question2 = $questiongenerator->create_question($qtype, $testquestion, ['category' => $cat->id]); quiz_add_quiz_question($question2->id, $quiz2, 0); // Update question2 to have the same stamp as question1. $DB->set_field('question', 'stamp', $question1->stamp, ['id' => $question2->id]); // Change the answers of the question2 to be different to question1. $question2data = \question_bank::load_question_data($question2->id); if (!isset($question2data->options->answers) || empty($question2data->options->answers)) { $this->markTestSkipped( "Cannot test edited answers for qtype_{$qtype} as it does not use answers.", ); } if ($DB->count_records('question_answers') === 0) { $this->markTestSkipped( "Cannot test edited answers for qtype_{$qtype} as it does not use the question_answers table.", ); } foreach ($question2data->options->answers as $answer) { $answer->answer = 'New answer ' . $answer->id; $DB->update_record('question_answers', $answer); } $course1q1structure = \mod_quiz\question\bank\qbank_helper::get_question_structure( $quiz1->id, \context_module::instance($quiz1->cmid)); $this->assertEquals($question1->id, $course1q1structure[1]->questionid); $course1q2structure = \mod_quiz\question\bank\qbank_helper::get_question_structure( $quiz2->id, \context_module::instance($quiz2->cmid)); $this->assertEquals($question2->id, $course1q2structure[1]->questionid); // Backup course1. $bc = new backup_controller(backup::TYPE_1COURSE, $course1->id, backup::FORMAT_MOODLE, backup::INTERACTIVE_NO, backup::MODE_IMPORT, $teacher->id); $backupid = $bc->get_backupid(); $bc->execute_plan(); $bc->destroy(); // Restore the backup, adding to course2. $rc = new restore_controller($backupid, $course2->id, backup::INTERACTIVE_NO, backup::MODE_IMPORT, $teacher->id, backup::TARGET_CURRENT_ADDING); $rc->execute_precheck(); $rc->execute_plan(); $rc->destroy(); // Verify that the newly-restored course's quizzes use the same questions as their counterparts of course1. $modules = get_fast_modinfo($course2->id)->get_instances_of('quiz'); $course1q1structure = \mod_quiz\question\bank\qbank_helper::get_question_structure( $quiz1->id, \context_module::instance($quiz1->cmid)); $course2quiz1 = array_shift($modules); $course2q1structure = \mod_quiz\question\bank\qbank_helper::get_question_structure( $course2quiz1->instance, $course2quiz1->context); $this->assertEquals($question1->id, $course1q1structure[1]->questionid); $this->assertEquals($question1->id, $course2q1structure[1]->questionid); $course1q2structure = \mod_quiz\question\bank\qbank_helper::get_question_structure( $quiz2->id, \context_module::instance($quiz2->cmid)); $course2quiz2 = array_shift($modules); $course2q2structure = \mod_quiz\question\bank\qbank_helper::get_question_structure( $course2quiz2->instance, $course2quiz2->context); $this->assertEquals($question2->id, $course1q2structure[1]->questionid); $this->assertEquals($question2->id, $course2q2structure[1]->questionid); } /** * Restore a quiz with questions of same stamp into the same course, but different hints. * * @dataProvider get_qtype_generators * @param string $qtype The name of the qtype plugin to test * @param ?string $testquestion The test question to generate for the plugin. If null, the plugin will be skipped * with a message. */ public function test_restore_quiz_with_same_stamp_questions_edited_hints(string $qtype, ?string $testquestion): void { global $DB, $USER; if (is_null($testquestion)) { $this->markTestSkipped( "Cannot test qtype_{$qtype} as there is no test question with a form_data method in the " . "test helper class." ); } $this->resetAfterTest(); $this->setAdminUser(); // Create a course and a user with editing teacher capabilities. $generator = $this->getDataGenerator(); $course1 = $generator->create_course(); $teacher = $USER; $generator->enrol_user($teacher->id, $course1->id, 'editingteacher'); $qbank = $generator->get_plugin_generator('mod_qbank')->create_instance(['course' => $course1->id]); $context = \context_module::instance($qbank->cmid); $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); // Create a question category. $cat = $questiongenerator->create_question_category(['contextid' => $context->id]); // Create 2 questions multichoice. $quiz1 = $this->create_test_quiz($course1); $question1 = $questiongenerator->create_question($qtype, $testquestion, ['category' => $cat->id]); quiz_add_quiz_question($question1->id, $quiz1, 0); $question2 = $questiongenerator->create_question($qtype, $testquestion, ['category' => $cat->id]); quiz_add_quiz_question($question2->id, $quiz1, 0); // Update question2 to have the same stamp as question1. $DB->set_field('question', 'stamp', $question1->stamp, ['id' => $question2->id]); // Change the hints of the question2 to be different to question1. $hints = $DB->get_records('question_hints', ['questionid' => $question2->id]); if (empty($hints)) { $this->markTestSkipped( "Cannot test edited hints for qtype_{$qtype} as test question {$testquestion} does not use hints.", ); } foreach ($hints as $hint) { $DB->set_field('question_hints', 'hint', "{$hint->hint} edited", ['id' => $hint->id]); } // Backup quiz1. $bc = new backup_controller(backup::TYPE_1ACTIVITY, $quiz1->cmid, backup::FORMAT_MOODLE, backup::INTERACTIVE_NO, backup::MODE_IMPORT, $teacher->id); $backupid = $bc->get_backupid(); $bc->execute_plan(); $bc->destroy(); // Restore the backup into the same course. $rc = new restore_controller($backupid, $course1->id, backup::INTERACTIVE_NO, backup::MODE_IMPORT, $teacher->id, backup::TARGET_CURRENT_ADDING); $rc->execute_precheck(); $rc->execute_plan(); $rc->destroy(); // Verify that the newly-restored quiz uses the same question as quiz2. $modules = get_fast_modinfo($course1->id)->get_instances_of('quiz'); $this->assertCount(2, $modules); $quiz1structure = \mod_quiz\question\bank\qbank_helper::get_question_structure( $quiz1->id, \context_module::instance($quiz1->cmid), ); $quiz2 = end($modules); $quiz2structure = \mod_quiz\question\bank\qbank_helper::get_question_structure($quiz2->instance, $quiz2->context); $this->assertEquals($quiz1structure[1]->questionid, $quiz2structure[1]->questionid); $this->assertEquals($quiz1structure[2]->questionid, $quiz2structure[2]->questionid); } /** * Return a set of options fields and new values. * * @return array */ public static function get_edited_option_fields(): array { return [ 'single' => [ 'single', '0', ], 'shuffleanswers' => [ 'shuffleanswers', '0', ], 'answernumbering' => [ 'answernumbering', 'ABCD', ], 'shownumcorrect' => [ 'shownumcorrect', '0', ], 'showstandardinstruction' => [ 'showstandardinstruction', '1', ], 'correctfeedback' => [ 'correctfeedback', 'edited', ], 'partiallycorrectfeedback' => [ 'partiallycorrectfeedback', 'edited', ], 'incorrectfeedback' => [ 'incorrectfeedback', 'edited', ], ]; } /** * Restore a quiz with questions of same stamp into the same course, but different qtype-specific options. * * @dataProvider get_edited_option_fields * @param string $field The answer field to edit * @param string $value The value to set */ public function test_restore_quiz_with_same_stamp_questions_edited_options(string $field, string $value): void { global $DB, $USER; $this->resetAfterTest(); $this->setAdminUser(); // Create a course and a user with editing teacher capabilities. $generator = $this->getDataGenerator(); $course1 = $generator->create_course(); $teacher = $USER; $generator->enrol_user($teacher->id, $course1->id, 'editingteacher'); $qbank = $generator->get_plugin_generator('mod_qbank')->create_instance(['course' => $course1->id]); $context = \context_module::instance($qbank->cmid); $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); // Create a question category. $cat = $questiongenerator->create_question_category(['contextid' => $context->id]); // A quiz with 2 multichoice questions. $quiz1 = $this->create_test_quiz($course1); $question1 = $questiongenerator->create_question('multichoice', 'one_of_four', ['category' => $cat->id]); quiz_add_quiz_question($question1->id, $quiz1, 0); $question2 = $questiongenerator->create_question('multichoice', 'one_of_four', ['category' => $cat->id]); quiz_add_quiz_question($question2->id, $quiz1, 0); // Update question2 to have the same stamp as question1. $DB->set_field('question', 'stamp', $question1->stamp, ['id' => $question2->id]); // Change the options of question2 to be different to question1. $DB->set_field('qtype_multichoice_options', $field, $value, ['questionid' => $question2->id]); // Backup quiz. $bc = new backup_controller(backup::TYPE_1ACTIVITY, $quiz1->cmid, backup::FORMAT_MOODLE, backup::INTERACTIVE_NO, backup::MODE_IMPORT, $teacher->id); $backupid = $bc->get_backupid(); $bc->execute_plan(); $bc->destroy(); // Restore the backup into the same course. $rc = new restore_controller($backupid, $course1->id, backup::INTERACTIVE_NO, backup::MODE_IMPORT, $teacher->id, backup::TARGET_CURRENT_ADDING); $rc->execute_precheck(); $rc->execute_plan(); $rc->destroy(); // Verify that the newly-restored quiz questions match their quiz1 counterparts. $modules = get_fast_modinfo($course1->id)->get_instances_of('quiz'); $this->assertCount(2, $modules); $quiz1structure = \mod_quiz\question\bank\qbank_helper::get_question_structure( $quiz1->id, \context_module::instance($quiz1->cmid), ); $quiz2 = end($modules); $quiz2structure = \mod_quiz\question\bank\qbank_helper::get_question_structure($quiz2->instance, $quiz2->context); $this->assertEquals($quiz1structure[1]->questionid, $quiz2structure[1]->questionid); $this->assertEquals($quiz1structure[2]->questionid, $quiz2structure[2]->questionid); } } PK9B\9L:e:e/tests/backup/backup_question_selection_test.phpnu[. namespace mod_quiz\backup; defined('MOODLE_INTERNAL') || die(); use mod_quiz\quiz_settings; use mod_quiz\structure; 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 . '/question/engine/lib.php'); require_once($CFG->dirroot . '/mod/quiz/locallib.php'); require_once($CFG->dirroot . '/course/lib.php'); require_once($CFG->dirroot . '/mod/quiz/tests/quiz_question_helper_test_trait.php'); /** * Unit tests ensuring only required questions are included in backups. * * @package mod_quiz * @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 \backup_questions_structure_step * @covers \backup_question_dbops * @covers \backup_quiz_activity_structure_step */ final class backup_question_selection_test extends \advanced_testcase { use \quiz_question_helper_test_trait; /** * Set up data to back up. * * A course contains a quiz and a qbank, and a second course contains a shared qbank. * Each of these contains a some categories and some questions. * The quiz uses 2 questions from its own question bank, plus 1 from the course qbank, 1 from the shared qbank, * and a random question selecting questions from a separate category in the shared qbank. * A user manages both courses. * * @return array The manager, quiz, questions and course records. */ protected function create_quiz_and_questions() { $manager = $this->getDataGenerator()->create_user(); $this->setUser($manager); $course = $this->getDataGenerator()->create_course(); $sharedcourse = $this->getDataGenerator()->create_course(); $this->getDataGenerator()->enrol_user($manager->id, $course->id, 'manager'); $this->getDataGenerator()->enrol_user($manager->id, $sharedcourse->id, 'manager'); $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); // Create some question banks and a quiz with 2 categories each. $courseqbank = self::getDataGenerator()->create_module('qbank', ['course' => $course->id]); $coursequestions = $questiongenerator->create_categories_and_questions( \context_module::instance($courseqbank->cmid), [ 'courseparentcat' => [ 'courseq1' => 'shortanswer', 'courseq2' => 'shortanswer', 'coursechildcat' => [ 'courseq3' => 'shortanswer', 'courseq4' => 'shortanswer', ], ], ] ); $sharedqbank = self::getDataGenerator()->create_module('qbank', ['course' => $sharedcourse->id]); $sharedquestions = $questiongenerator->create_categories_and_questions( \context_module::instance($sharedqbank->cmid), [ 'sharedparentcat' => [ 'sharedq1' => 'shortanswer', 'sharedq2' => 'shortanswer', 'sharedchildcat' => [ 'sharedq3' => 'shortanswer', 'sharedq4' => 'shortanswer', ], ], 'tagcat1' => [ 'tagq1' => 'shortanswer', 'tagq2' => 'shortanswer', 'tagq3' => 'shortanswer', ], 'tagcat2' => [ 'tagq4' => 'shortanswer', 'tagq5' => 'shortanswer', 'tagq6' => 'shortanswer', ], ] ); $quiz = $this->create_test_quiz($course); $quizquestions = $questiongenerator->create_categories_and_questions( \context_module::instance($quiz->cmid), [ 'quizparentcat' => [ 'quizq1' => 'shortanswer', 'quizq2' => 'shortanswer', 'quizchildcat' => [ 'quizq3' => 'shortanswer', 'quizq4' => 'shortanswer', ], ], ] ); $questiongenerator->create_question_tag(['questionid' => $sharedquestions['tagcat1']['tagq1']->id, 'tag' => 'mytag']); $questiongenerator->create_question_tag(['questionid' => $sharedquestions['tagcat1']['tagq2']->id, 'tag' => 'mytag']); $questiongenerator->create_question_tag(['questionid' => $sharedquestions['tagcat2']['tagq5']->id, 'tag' => 'mytag']); $tags = \core_tag_tag::get_item_tags('core_question', 'question', $sharedquestions['tagcat1']['tagq1']->id); $mytag = reset($tags); // Add a question from the shared bank child category. quiz_add_quiz_question($sharedquestions['sharedparentcat']['sharedchildcat']['sharedq3']->id, $quiz); // Add a question from the course bank parent category. quiz_add_quiz_question($coursequestions['courseparentcat']['courseq2']->id, $quiz); // Add a question from the quiz bank categories. quiz_add_quiz_question($quizquestions['quizparentcat']['quizq1']->id, $quiz); // Add random question to select tagged questions from 2 different categories. $settings = quiz_settings::create($quiz->id); $structure = structure::create_for_quiz($settings); $structure->add_random_questions(1, 1, [ 'filter' => [ 'category' => [ 'jointype' => \core\output\datafilter::JOINTYPE_ANY, 'values' => [$sharedquestions['tagcat1']['tagq1']->category], 'filteroptions' => ['includesubcategories' => false], ], 'qtagids' => [ 'jointype' => \core\output\datafilter::JOINTYPE_ANY, 'values' => [$mytag->id], ], ], ]); $structure->add_random_questions(1, 1, [ 'filter' => [ 'category' => [ 'jointype' => \core\output\datafilter::JOINTYPE_ANY, 'values' => [$sharedquestions['tagcat2']['tagq4']->category], 'filteroptions' => ['includesubcategories' => false], ], 'qtagids' => [ 'jointype' => \core\output\datafilter::JOINTYPE_ANY, 'values' => [$mytag->id], ], ], ]); return [ $manager, $quiz, $quizquestions, $coursequestions, $sharedquestions, $course, ]; } /** * Test that backing up a quiz only includes the questions owned or used by the quiz. */ public function test_quiz_backup_excludes_unused_questions(): void { global $DB; $this->resetAfterTest(); [ $manager, $quiz, $quizquestions, $coursequestions, $sharedquestions, ] = $this->create_quiz_and_questions(); // Backup the quiz. $bc = new \backup_controller( \backup::TYPE_1ACTIVITY, $quiz->cmid, \backup::FORMAT_MOODLE, \backup::INTERACTIVE_NO, \backup::MODE_IMPORT, $manager->id, ); $backupid = $bc->get_backupid(); $bc->execute_plan(); $bc->destroy(); $course2 = $this->getDataGenerator()->create_course(); $this->getDataGenerator()->enrol_user($manager->id, $course2->id, 'manager'); $rc = new \restore_controller($backupid, $course2->id, \backup::INTERACTIVE_NO, \backup::MODE_IMPORT, $manager->id, \backup::TARGET_CURRENT_ADDING); $rc->execute_precheck(); $backupquestions = $DB->get_records_menu('backup_ids_temp', ['itemname' => 'question'], '', 'id, itemid'); // Backup should contain used questions from shared qbanks. $this->assertContains((string) $sharedquestions['sharedparentcat']['sharedchildcat']['sharedq3']->id, $backupquestions); $this->assertContains((string) $coursequestions['courseparentcat']['courseq2']->id, $backupquestions); // Backup should contain all questions from quiz's bank. $this->assertContains((string) $quizquestions['quizparentcat']['quizq1']->id, $backupquestions); $this->assertContains((string) $quizquestions['quizparentcat']['quizq2']->id, $backupquestions); $this->assertContains((string) $quizquestions['quizparentcat']['quizchildcat']['quizq3']->id, $backupquestions); $this->assertContains((string) $quizquestions['quizparentcat']['quizchildcat']['quizq4']->id, $backupquestions); // Backup should contain questions matched by random question filters. $this->assertContains((string) $sharedquestions['tagcat1']['tagq1']->id, $backupquestions); $this->assertContains((string) $sharedquestions['tagcat1']['tagq2']->id, $backupquestions); $this->assertContains((string) $sharedquestions['tagcat2']['tagq5']->id, $backupquestions); // All other questions should be excluded. $this->assertNotContains((string) $sharedquestions['sharedparentcat']['sharedq1']->id, $backupquestions); $this->assertNotContains((string) $sharedquestions['sharedparentcat']['sharedq2']->id, $backupquestions); $this->assertNotContains((string) $sharedquestions['sharedparentcat']['sharedchildcat']['sharedq4']->id, $backupquestions); $this->assertNotContains((string) $coursequestions['courseparentcat']['courseq1']->id, $backupquestions); $this->assertNotContains((string) $coursequestions['courseparentcat']['coursechildcat']['courseq3']->id, $backupquestions); $this->assertNotContains((string) $coursequestions['courseparentcat']['coursechildcat']['courseq4']->id, $backupquestions); $this->assertNotContains((string) $sharedquestions['tagcat1']['tagq3']->id, $backupquestions); $this->assertNotContains((string) $sharedquestions['tagcat2']['tagq4']->id, $backupquestions); $this->assertNotContains((string) $sharedquestions['tagcat2']['tagq6']->id, $backupquestions); $this->assertCount(9, $backupquestions); // Clean up. $rc->execute_plan(); $rc->destroy(); } /** * Test that backing up a quiz only includes the questions owned or used by the quiz * when the quiz has legacy JSON values in the question_set_references table. */ public function test_quiz_backup_with_legacy_reference_filter_condition(): void { global $DB; $this->resetAfterTest(); [ $manager, $quiz, $quizquestions, $coursequestions, $sharedquestions, ] = $this->create_quiz_and_questions(); // Revert the filtercondition to the legacy JSON format. $questionsetreferences = $DB->get_records('question_set_references'); $questionsetreference = reset($questionsetreferences); $filtercondition = json_decode($questionsetreference->filtercondition); $tag = \core_tag_tag::get($filtercondition->filter->qtagids->values[0]); $questionsetreference->filtercondition = json_encode([ 'questioncategoryid' => $filtercondition->filter->category->values[0], 'includingsubcategories' => $filtercondition->filter->category->filteroptions->includesubcategories, 'tags' => ["$tag->id,$tag->name"], ]); $DB->update_record('question_set_references', $questionsetreference); // Backup the quiz. $bc = new \backup_controller( \backup::TYPE_1ACTIVITY, $quiz->cmid, \backup::FORMAT_MOODLE, \backup::INTERACTIVE_NO, \backup::MODE_IMPORT, $manager->id, ); $backupid = $bc->get_backupid(); $bc->execute_plan(); $bc->destroy(); $course2 = $this->getDataGenerator()->create_course(); $this->getDataGenerator()->enrol_user($manager->id, $course2->id, 'manager'); $rc = new \restore_controller( $backupid, $course2->id, \backup::INTERACTIVE_NO, \backup::MODE_IMPORT, $manager->id, \backup::TARGET_CURRENT_ADDING ); $rc->execute_precheck(); $backupquestions = $DB->get_records_menu('backup_ids_temp', ['itemname' => 'question'], '', 'id, itemid'); // Backup should contain used questions from shared qbanks. $this->assertContains((string) $sharedquestions['sharedparentcat']['sharedchildcat']['sharedq3']->id, $backupquestions); $this->assertContains((string) $coursequestions['courseparentcat']['courseq2']->id, $backupquestions); // Backup should contain all questions from quiz's bank. $this->assertContains((string) $quizquestions['quizparentcat']['quizq1']->id, $backupquestions); $this->assertContains((string) $quizquestions['quizparentcat']['quizq2']->id, $backupquestions); $this->assertContains((string) $quizquestions['quizparentcat']['quizchildcat']['quizq3']->id, $backupquestions); $this->assertContains((string) $quizquestions['quizparentcat']['quizchildcat']['quizq4']->id, $backupquestions); // Backup should contain questions matched by random question filters. $this->assertContains((string) $sharedquestions['tagcat1']['tagq1']->id, $backupquestions); $this->assertContains((string) $sharedquestions['tagcat1']['tagq2']->id, $backupquestions); $this->assertContains((string) $sharedquestions['tagcat2']['tagq5']->id, $backupquestions); // All other questions should be excluded. $this->assertNotContains((string) $sharedquestions['sharedparentcat']['sharedq1']->id, $backupquestions); $this->assertNotContains((string) $sharedquestions['sharedparentcat']['sharedq2']->id, $backupquestions); $this->assertNotContains((string) $sharedquestions['sharedparentcat']['sharedchildcat']['sharedq4']->id, $backupquestions); $this->assertNotContains((string) $coursequestions['courseparentcat']['courseq1']->id, $backupquestions); $this->assertNotContains((string) $coursequestions['courseparentcat']['coursechildcat']['courseq3']->id, $backupquestions); $this->assertNotContains((string) $coursequestions['courseparentcat']['coursechildcat']['courseq4']->id, $backupquestions); $this->assertNotContains((string) $sharedquestions['tagcat1']['tagq3']->id, $backupquestions); $this->assertNotContains((string) $sharedquestions['tagcat2']['tagq4']->id, $backupquestions); $this->assertNotContains((string) $sharedquestions['tagcat2']['tagq6']->id, $backupquestions); $this->assertCount(9, $backupquestions); // Clean up. $rc->execute_plan(); $rc->destroy(); } /** * Test that backing up a quiz only includes the questions used in or belonging to the course. * * This should include all questions in categories belonging to quizzes or qbanks on the course, plus questions from outside * the course used by quizzes. */ public function test_course_backup_excludes_unused_questions(): void { global $DB; $this->resetAfterTest(); [ $manager, , $quizquestions, $coursequestions, $sharedquestions, $course, ] = $this->create_quiz_and_questions(); // Backup the course. $bc = new \backup_controller( \backup::TYPE_1COURSE, $course->id, \backup::FORMAT_MOODLE, \backup::INTERACTIVE_NO, \backup::MODE_IMPORT, $manager->id, ); $backupid = $bc->get_backupid(); $bc->execute_plan(); $bc->destroy(); $course2 = $this->getDataGenerator()->create_course(); $this->getDataGenerator()->enrol_user($manager->id, $course2->id, 'manager'); $rc = new \restore_controller($backupid, $course2->id, \backup::INTERACTIVE_NO, \backup::MODE_IMPORT, $manager->id, \backup::TARGET_CURRENT_ADDING); $rc->execute_precheck(); $backupquestions = $DB->get_records_menu('backup_ids_temp', ['itemname' => 'question'], '', 'id, itemid'); // Backup should contain used questions from shared qbanks. $this->assertContains((string) $sharedquestions['sharedparentcat']['sharedchildcat']['sharedq3']->id, $backupquestions); // Backup should contain all questions from course qbanks. $this->assertContains((string) $coursequestions['courseparentcat']['courseq1']->id, $backupquestions); $this->assertContains((string) $coursequestions['courseparentcat']['courseq2']->id, $backupquestions); $this->assertContains((string) $coursequestions['courseparentcat']['coursechildcat']['courseq3']->id, $backupquestions); $this->assertContains((string) $coursequestions['courseparentcat']['coursechildcat']['courseq4']->id, $backupquestions); // Backup should contain all questions from quiz's bank. $this->assertContains((string) $quizquestions['quizparentcat']['quizq1']->id, $backupquestions); $this->assertContains((string) $quizquestions['quizparentcat']['quizq2']->id, $backupquestions); $this->assertContains((string) $quizquestions['quizparentcat']['quizchildcat']['quizq3']->id, $backupquestions); $this->assertContains((string) $quizquestions['quizparentcat']['quizchildcat']['quizq4']->id, $backupquestions); // Backup should contain questions matched by random question filters. $this->assertContains((string) $sharedquestions['tagcat1']['tagq1']->id, $backupquestions); $this->assertContains((string) $sharedquestions['tagcat1']['tagq2']->id, $backupquestions); $this->assertContains((string) $sharedquestions['tagcat2']['tagq5']->id, $backupquestions); // All other questions should be excluded. $this->assertNotContains((string) $sharedquestions['sharedparentcat']['sharedq1']->id, $backupquestions); $this->assertNotContains((string) $sharedquestions['sharedparentcat']['sharedq2']->id, $backupquestions); $this->assertNotContains((string) $sharedquestions['sharedparentcat']['sharedchildcat']['sharedq4']->id, $backupquestions); $this->assertNotContains((string) $sharedquestions['tagcat1']['tagq3']->id, $backupquestions); $this->assertNotContains((string) $sharedquestions['tagcat2']['tagq4']->id, $backupquestions); $this->assertNotContains((string) $sharedquestions['tagcat2']['tagq6']->id, $backupquestions); $this->assertCount(12, $backupquestions); // Clean up. $rc->execute_plan(); $rc->destroy(); } /** * Backing up a quiz using questions with children includes those children in the backup. */ public function test_quiz_backup_includes_child_questions(): void { global $DB; $this->resetAfterTest(); $manager = $this->getDataGenerator()->create_user(); $this->setUser($manager); $course = $this->getDataGenerator()->create_course(); $sharedcourse = $this->getDataGenerator()->create_course(); $this->getDataGenerator()->enrol_user($manager->id, $course->id, 'manager'); $this->getDataGenerator()->enrol_user($manager->id, $sharedcourse->id, 'manager'); $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); // Create a question bank with 3 shared questions, each with 2 children. $courseqbank = self::getDataGenerator()->create_module('qbank', ['course' => $course->id]); $coursequestions = $questiongenerator->create_categories_and_questions( \context_module::instance($courseqbank->cmid), [ 'courseparentcat' => [ 'courseq1' => 'multianswer', 'courseq2' => 'multianswer', ], 'randomcat' => [ 'courseq3' => 'multianswer', ], ] ); $coursechildquestions = [ 'courseq1' => $DB->get_records( 'question', ['parent' => $coursequestions['courseparentcat']['courseq1']->id], ), 'courseq2' => $DB->get_records( 'question', ['parent' => $coursequestions['courseparentcat']['courseq2']->id], ), 'courseq3' => $DB->get_records( 'question', ['parent' => $coursequestions['randomcat']['courseq3']->id], ), ]; $quiz = $this->create_test_quiz($course); $quizquestions = $questiongenerator->create_categories_and_questions( \context_module::instance($quiz->cmid), [ 'quizcat' => [ 'quizq1' => 'multianswer', 'quizq2' => 'multianswer', ], ] ); $quizchildquestions = [ 'quizq1' => $DB->get_records( 'question', ['parent' => $quizquestions['quizcat']['quizq1']->id], ), 'quizq2' => $DB->get_records( 'question', ['parent' => $quizquestions['quizcat']['quizq2']->id], ), ]; // Add a question from the course bank parent category. quiz_add_quiz_question($coursequestions['courseparentcat']['courseq2']->id, $quiz); // A question from quiz bank. quiz_add_quiz_question($quizquestions['quizcat']['quizq2']->id, $quiz); // Add a random question from the course bank. $settings = quiz_settings::create($quiz->id); $structure = structure::create_for_quiz($settings); $structure->add_random_questions(1, 1, [ 'filter' => [ 'category' => [ 'jointype' => \core\output\datafilter::JOINTYPE_ANY, 'values' => [$coursequestions['randomcat']['courseq3']->category], 'filteroptions' => ['includesubcategories' => false], ], ], ]); // Backup the quiz. $bc = new \backup_controller( \backup::TYPE_1ACTIVITY, $quiz->cmid, \backup::FORMAT_MOODLE, \backup::INTERACTIVE_NO, \backup::MODE_IMPORT, $manager->id, ); $backupid = $bc->get_backupid(); $bc->execute_plan(); $bc->destroy(); $course2 = $this->getDataGenerator()->create_course(); $this->getDataGenerator()->enrol_user($manager->id, $course2->id, 'manager'); $rc = new \restore_controller( $backupid, $course2->id, \backup::INTERACTIVE_NO, \backup::MODE_IMPORT, $manager->id, \backup::TARGET_CURRENT_ADDING, ); $rc->execute_precheck(); $backupquestions = $DB->get_records_menu('backup_ids_temp', ['itemname' => 'question'], '', 'id, itemid'); // Backup should contain the used parent category question and its children. $this->assertContains((string) $coursequestions['courseparentcat']['courseq2']->id, $backupquestions); foreach ($coursechildquestions['courseq2'] as $childquestion) { $this->assertContains((string) $childquestion->id, $backupquestions); } // Backup should not contain the parent category question or its children. $this->assertNotContains((string) $coursequestions['courseparentcat']['courseq1']->id, $backupquestions); foreach ($coursechildquestions['courseq1'] as $childquestion) { $this->assertNotContains((string) $childquestion->id, $backupquestions); } // Backup should contain the random category question and its children. $this->assertContains((string) $coursequestions['randomcat']['courseq3']->id, $backupquestions); foreach ($coursechildquestions['courseq3'] as $childquestion) { $this->assertContains((string) $childquestion->id, $backupquestions); } // Backup should contain all quiz category questions and their children, used or unused. $this->assertContains((string) $quizquestions['quizcat']['quizq1']->id, $backupquestions); foreach ($quizchildquestions['quizq1'] as $childquestion) { $this->assertContains((string) $childquestion->id, $backupquestions); } $this->assertContains((string) $quizquestions['quizcat']['quizq2']->id, $backupquestions); foreach ($quizchildquestions['quizq2'] as $childquestion) { $this->assertContains((string) $childquestion->id, $backupquestions); } // Clean up. $rc->execute_plan(); $rc->destroy(); } } PK9B\s*>>"tests/backup/restore_date_test.phpnu[. namespace mod_quiz\backup; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->libdir . "/phpunit/classes/restore_date_testcase.php"); /** * Restore date tests. * * @package mod_quiz * @copyright 2017 onwards Ankit Agarwal * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class restore_date_test extends \restore_date_testcase { /** * Test restore dates. */ public function test_restore_dates(): void { global $DB, $USER; // Create quiz data. $record = [ 'timeopen' => 100, 'timeclose' => 100, 'timemodified' => 100, 'timecreated' => 100, 'questionsperpage' => 0, 'grade' => 100.0, 'sumgrades' => 2, 'precreateattempts' => 1, ]; list($course, $quiz) = $this->create_course_and_module('quiz', $record); // Create questions. $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); $cat = $questiongenerator->create_question_category(); $saq = $questiongenerator->create_question('shortanswer', null, ['category' => $cat->id]); // Add to the quiz. quiz_add_quiz_question($saq->id, $quiz); // Create an attempt. $timestamp = 100; $quizobj = \mod_quiz\quiz_settings::create($quiz->id); $attempt = quiz_create_attempt($quizobj, 1, false, $timestamp, false); $quba = \question_engine::make_questions_usage_by_activity('mod_quiz', $quizobj->get_context()); $quba->set_preferred_behaviour($quizobj->get_quiz()->preferredbehaviour); quiz_start_new_attempt($quizobj, $quba, $attempt, 1, $timestamp); quiz_attempt_save_started($quizobj, $quba, $attempt, $timestamp); // Quiz grade. $grade = new \stdClass(); $grade->quiz = $quiz->id; $grade->userid = $USER->id; $grade->grade = 8.9; $grade->timemodified = $timestamp; $grade->id = $DB->insert_record('quiz_grades', $grade); // User override. $override = (object)[ 'quiz' => $quiz->id, 'groupid' => 0, 'userid' => $USER->id, 'sortorder' => 1, 'timeopen' => 100, 'timeclose' => 200 ]; $DB->insert_record('quiz_overrides', $override); // Set time fields to a constant for easy validation. $DB->set_field('quiz_attempts', 'timefinish', $timestamp); // Do backup and restore. $newcourseid = $this->backup_and_restore($course); $newquiz = $DB->get_record('quiz', ['course' => $newcourseid]); $this->assertFieldsNotRolledForward($quiz, $newquiz, ['timecreated', 'timemodified']); $props = ['timeclose', 'timeopen']; $this->assertFieldsRolledForward($quiz, $newquiz, $props); $this->assertEquals($quiz->precreateattempts, $newquiz->precreateattempts); $newattempt = $DB->get_record('quiz_attempts', ['quiz' => $newquiz->id]); $newoverride = $DB->get_record('quiz_overrides', ['quiz' => $newquiz->id]); $newgrade = $DB->get_record('quiz_grades', ['quiz' => $newquiz->id]); // Attempt time checks. $diff = $this->get_diff(); $this->assertEquals($timestamp, $newattempt->timemodified); $this->assertEquals($timestamp, $newattempt->timefinish); $this->assertEquals($timestamp, $newattempt->timestart); $this->assertEquals($timestamp + $diff, $newattempt->timecheckstate); // Should this be rolled? // Quiz override time checks. $diff = $this->get_diff(); $this->assertEquals($override->timeopen + $diff, $newoverride->timeopen); $this->assertEquals($override->timeclose + $diff, $newoverride->timeclose); // Quiz grade time checks. $this->assertEquals($grade->timemodified, $newgrade->timemodified); } } PK9B\P tests/backup/restore_39_test.phpnu[. namespace mod_quiz\backup; use advanced_testcase; use backup; use restore_controller; /** * Test restoring 3.9 backups including random questions. * * @package mod_quiz * @copyright 2024 Tomo Tsuyuki * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @covers \restore_move_module_questions_categories */ final class restore_39_test extends advanced_testcase { public function test_restore_random_question_39(): void { global $DB, $CFG, $USER; require_once($CFG->dirroot . '/backup/util/includes/restore_includes.php'); $this->resetAfterTest(); $this->setAdminUser(); // The example Moodle 3.9 backup file used in this test is an activity-level backup of a quiz. // So, the backup contains just the quiz-level question bank which contains: // | - Question category: Top // | - Question category: Default for Test MDL-78902 quiz // | - Question: Test MDL-78902 T/F question // | - Question: Random (Default for Test MDL-78902 quiz) // The quiz itself contains 1 question, the random question. // So, during the restore, the quiz_slot needs to be updated to use a question_set_reference. $backupfile = 'moodle_39_quiz_with_random_question_from_mod_context'; // Extract backup file. $backupid = $backupfile; $backuppath = make_backup_temp_directory($backupid); get_file_packer('application/vnd.moodle.backup')->extract_to_pathname( __DIR__ . "/../fixtures/$backupfile.mbz", $backuppath); // Restore the quiz activity in the backup from Moodle 3.9 to a new course. $coursecat = self::getDataGenerator()->create_category(); $course = self::getDataGenerator()->create_course(['category' => $coursecat->id]); $rc = new restore_controller($backupid, $course->id, backup::INTERACTIVE_NO, backup::MODE_GENERAL, $USER->id, backup::TARGET_EXISTING_ADDING); $this->assertTrue($rc->execute_precheck()); $rc->execute_plan(); $rc->destroy(); // Get information about the quiz activity and confirm the references are correct. $modinfo = get_fast_modinfo($course->id); $quizzes = array_values($modinfo->get_instances_of('quiz')); // Get contextid for the restored quiz activity. $contextid = $quizzes[0]->context->id; $qcats = $DB->get_records('question_categories', ['contextid' => $contextid], 'parent'); // Confirm there are 2 question categories for the restored quiz activity. $this->assertEquals(['top', 'Default for Test MDL-78902 quiz'], array_column($qcats, 'name')); // Get question_set_references records for the restored quiz activity. $references = $DB->get_records('question_set_references', ['usingcontextid' => $contextid]); foreach ($references as $reference) { $filtercondition = json_decode($reference->filtercondition); // Confirm the questionscontextid is set correctly, which is from filter question category id. $this->assertEquals($reference->questionscontextid, $qcats[$filtercondition->questioncategoryid]->contextid); } } } PK9B\ {0gg.tests/backup/restore_quiz_grade_items_test.phpnu[. namespace mod_quiz; use core_question_generator; use mod_quiz_generator; use restore_date_testcase; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->libdir . "/phpunit/classes/restore_date_testcase.php"); /** * Test of backup and restore of quiz grade items. * * @package mod_quiz * @copyright 2023 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @covers \backup_quiz_activity_structure_step * @covers \restore_quiz_activity_structure_step */ final class restore_quiz_grade_items_test extends restore_date_testcase { public function test_restore_quiz_grade_items(): void { global $DB; $this->resetAfterTest(); $generator = $this->getDataGenerator(); /** @var mod_quiz_generator $quizgen */ $quizgen = $this->getDataGenerator()->get_plugin_generator('mod_quiz'); /** @var core_question_generator $questiongenerator */ $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); // Create a quiz with two grade items. $course = $generator->create_course(); $quiz = $quizgen->create_instance(['course' => $course->id]); $listeninggrade = $quizgen->create_grade_item(['quizid' => $quiz->id, 'name' => 'Listening']); $readinggrade = $quizgen->create_grade_item(['quizid' => $quiz->id, 'name' => 'Reading']); // Add two questions to the quiz. $cat = $questiongenerator->create_question_category(); $saq1 = $questiongenerator->create_question('shortanswer', null, ['category' => $cat->id]); $saq2 = $questiongenerator->create_question('shortanswer', null, ['category' => $cat->id]); quiz_add_quiz_question($saq1->id, $quiz, 0, 1); quiz_add_quiz_question($saq2->id, $quiz, 0, 1); // Set one of the question to use a grade item. $quizobj = quiz_settings::create($quiz->id); $structure = $quizobj->get_structure(); $structure->update_slot_grade_item($structure->get_slot_by_number(2), $readinggrade->id); $quizobj->get_grade_calculator()->recompute_quiz_sumgrades(); // Back up and restore the course. $newcourseid = $this->backup_and_restore($course); // Verify the grade items were copied over. $newquiz = $DB->get_record('quiz', ['course' => $newcourseid]); $quizobj = quiz_settings::create($newquiz->id); $structure = $quizobj->get_structure(); $quizgradeitems = array_values($structure->get_grade_items()); // Check the grade items are right in the restored quiz. $this->assertEquals( [ (object) ['id' => reset($quizgradeitems)->id, 'quizid' => $newquiz->id, 'sortorder' => 1, 'name' => 'Listening'], (object) ['id' => end($quizgradeitems)->id, 'quizid' => $newquiz->id, 'sortorder' => 2, 'name' => 'Reading'], ], array_values($quizgradeitems), ); // Verify that each slot uses the right grade item. $this->assertNull($structure->get_slot_by_number(1)->quizgradeitemid); $this->assertEquals(end($quizgradeitems)->id, $structure->get_slot_by_number(2)->quizgradeitemid); } } PK9B\& ;;tests/generator/lib.phpnu[. use mod_quiz\quiz_attempt; use mod_quiz\quiz_settings; defined('MOODLE_INTERNAL') || die(); /** * Quiz module test data generator class * * @package mod_quiz * @copyright 2012 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class mod_quiz_generator extends testing_module_generator { public function create_instance($record = null, ?array $options = null) { global $CFG; require_once($CFG->dirroot.'/mod/quiz/locallib.php'); $record = (object)(array)$record; $defaultquizsettings = [ 'timeopen' => 0, 'timeclose' => 0, 'preferredbehaviour' => 'deferredfeedback', 'attempts' => 0, 'attemptonlast' => 0, 'grademethod' => QUIZ_GRADEHIGHEST, 'decimalpoints' => 2, 'questiondecimalpoints' => -1, 'attemptduring' => 1, 'correctnessduring' => 1, 'maxmarksduring' => 1, 'marksduring' => 1, 'specificfeedbackduring' => 1, 'generalfeedbackduring' => 1, 'rightanswerduring' => 1, 'overallfeedbackduring' => 0, 'attemptimmediately' => 1, 'correctnessimmediately' => 1, 'maxmarksimmediately' => 1, 'marksimmediately' => 1, 'specificfeedbackimmediately' => 1, 'generalfeedbackimmediately' => 1, 'rightanswerimmediately' => 1, 'overallfeedbackimmediately' => 1, 'attemptopen' => 1, 'correctnessopen' => 1, 'maxmarksopen' => 1, 'marksopen' => 1, 'specificfeedbackopen' => 1, 'generalfeedbackopen' => 1, 'rightansweropen' => 1, 'overallfeedbackopen' => 1, 'attemptclosed' => 1, 'correctnessclosed' => 1, 'maxmarksclosed' => 1, 'marksclosed' => 1, 'specificfeedbackclosed' => 1, 'generalfeedbackclosed' => 1, 'rightanswerclosed' => 1, 'overallfeedbackclosed' => 1, 'questionsperpage' => 1, 'shuffleanswers' => 1, 'sumgrades' => 0, 'grade' => 100, 'timecreated' => time(), 'timemodified' => time(), 'timelimit' => 0, 'overduehandling' => 'autosubmit', 'graceperiod' => 86400, 'quizpassword' => '', 'subnet' => '', 'browsersecurity' => '', 'delay1' => 0, 'delay2' => 0, 'showuserpicture' => 0, 'showblocks' => 0, 'navmethod' => QUIZ_NAVMETHOD_FREE, ]; foreach ($defaultquizsettings as $name => $value) { if (!isset($record->{$name})) { $record->{$name} = $value; } } if (isset($record->gradepass)) { $record->gradepass = unformat_float($record->gradepass); } return parent::create_instance($record, (array)$options); } /** * Create a quiz attempt for a particular user at a particular course. * * @param int $quizid the quiz id (from the mdl_quit table, not cmid). * @param int $userid the user id. * @param array $forcedrandomquestions slot => questionid. Optional, * used with random questions, to control which one is 'randomly' selected in that slot. * @param array $forcedvariants slot => variantno. Optional. Optional, * used with question where get_num_variants is > 1, to control which * variants is 'randomly' selected. * @return stdClass the new attempt. */ public function create_attempt($quizid, $userid, array $forcedrandomquestions = [], array $forcedvariants = []) { // Build quiz object and load questions. $quizobj = quiz_settings::create($quizid, $userid); $attemptnumber = 1; $attempt = null; if ($attempts = quiz_get_user_attempts($quizid, $userid, 'all', true)) { // There is/are already an attempt/some attempts. // Take the last attempt. $attempt = end($attempts); // Take the attempt number of the last attempt and increase it. $attemptnumber = $attempt->attempt + 1; } return quiz_prepare_and_start_new_attempt($quizobj, $attemptnumber, $attempt, false, $forcedrandomquestions, $forcedvariants); } /** * Submit responses to a quiz attempt. * * To be realistic, you should ensure that $USER is set to the user whose attempt * it is before calling this. * * @param int $attemptid the id of the attempt which is being * @param array $responses array responses to submit. See description on * {@link core_question_generator::get_simulated_post_data_for_questions_in_usage()}. * @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. * @param bool $finishattempt if true, the attempt will be submitted. * @param int|null $timefinish the time to set as the time finished for the attempt. */ public function submit_responses($attemptid, array $responses, $checkbutton, $finishattempt, ?int $timefinish = null) { $questiongenerator = $this->datagenerator->get_plugin_generator('core_question'); $attemptobj = quiz_attempt::create($attemptid); $postdata = $questiongenerator->get_simulated_post_data_for_questions_in_usage( $attemptobj->get_question_usage(), $responses, $checkbutton); $attemptobj->process_submitted_actions(time(), false, $postdata); // Bit if a hack for interactive behaviour. // TODO handle this in a more plugin-friendly way. if ($checkbutton) { $postdata = []; foreach ($responses as $slot => $notused) { $qa = $attemptobj->get_question_attempt($slot); if ($qa->get_behaviour() instanceof qbehaviour_interactive && $qa->get_behaviour()->is_try_again_state()) { $postdata[$qa->get_control_field_name('sequencecheck')] = (string)$qa->get_sequence_check_count(); $postdata[$qa->get_flag_field_name()] = (string)(int)$qa->is_flagged(); $postdata[$qa->get_behaviour_field_name('tryagain')] = 1; } } if ($postdata) { $attemptobj->process_submitted_actions(time(), false, $postdata); } } if ($finishattempt) { // If a timefinish is not specified, use the current time. $timefinish = $timefinish ?? time(); $attemptobj->process_submit($timefinish, false); $attemptobj->process_grade_submission(time()); } } /** * Create a quiz override (either user or group). * * @param array $data must specify quizid, and one of userid or groupid. */ public function create_override(array $data): void { global $DB; // Validate. if (!isset($data['quiz'])) { throw new coding_exception('Must specify quiz (id) when creating a quiz override.'); } if (!isset($data['userid']) && !isset($data['groupid'])) { throw new coding_exception('Must specify one of userid or groupid when creating a quiz override.'); } if (isset($data['userid']) && isset($data['groupid'])) { throw new coding_exception('Cannot specify both userid and groupid when creating a quiz override.'); } // Create the override. $DB->insert_record('quiz_overrides', (object) $data); // Update any associated calendar events, if necessary. quiz_update_events($DB->get_record('quiz', ['id' => $data['quiz']], '*', MUST_EXIST)); } /** * Create a quiz override (either user or group). * * @param array $data must specify quizid and a name. * @return stdClass the newly created quiz_grade_items row. */ public function create_grade_item(array $data): stdClass { global $DB; // Validate. if (!isset($data['quizid'])) { throw new coding_exception('Must specify quizid when creating a quiz grade item.'); } if (!isset($data['name'])) { throw new coding_exception('Must specify a name when creating a quiz grade item.'); } if (clean_param($data['name'], PARAM_TEXT) !== $data['name']) { throw new coding_exception('Grade item name must be PARAM_TEXT.'); } $data['sortorder'] = $DB->get_field('quiz_grade_items', 'COALESCE(MAX(sortorder) + 1, 1)', ['quizid' => $data['quizid']]); // Create the grade item. $gradeitem = (object) $data; $gradeitem->id = $DB->insert_record('quiz_grade_items', $gradeitem); return $gradeitem; } /** * Create the structure of a quiz according to the provided layout. * * @param stdClass $quiz The quiz object. * @param array $layout Layout describing questions and sections. * @param int|null $categoryid Optional question category id. */ public function create_quiz_structure($quiz, array $layout, ?int $categoryid = null): void { $questiongenerator = $this->datagenerator->get_plugin_generator('core_question'); $catid = $categoryid ?? $questiongenerator->create_question_category()->id; $headings = []; $lastpage = 0; $cm = get_coursemodule_from_instance('quiz', $quiz->id, $quiz->course); $course = get_course($quiz->course); $quizobj = new quiz_settings($quiz, $cm, $course); foreach ($layout as $item) { if (is_string($item)) { if (isset($headings[$lastpage + 1])) { throw new \coding_exception('Sections cannot be empty.'); } $headings[$lastpage + 1] = $item; } else { [$name, $page, $qtype] = $item; $structure = \mod_quiz\structure::create_for_quiz($quizobj); if ($page === 0) { $slots = $structure->get_slots(); $lastslot = end($slots); $page = $lastslot ? $lastslot->page : 1; } if ($page < 1 || !($page == $lastpage + 1 || (!isset($headings[$lastpage + 1]) && $page == $lastpage))) { throw new \coding_exception('Page numbers wrong.'); } $q = $questiongenerator->create_question($qtype, null, ['name' => $name, 'category' => $catid]); quiz_add_quiz_question($q->id, $quiz, $page); $lastpage = $page; } } // Section heading logic. $structure = \mod_quiz\structure::create_for_quiz($quizobj); if (isset($headings[1])) { [$heading, $shuffle] = $this->parse_section_name($headings[1]); $sections = $structure->get_sections(); $firstsection = reset($sections); $structure->set_section_heading($firstsection->id, $heading); $structure->set_section_shuffle($firstsection->id, $shuffle); unset($headings[1]); } foreach ($headings as $startpage => $heading) { [$heading, $shuffle] = $this->parse_section_name($heading); $id = $structure->add_section_heading($startpage, $heading); $structure->set_section_shuffle($id, $shuffle); } } /** * Creat a test quiz. * * $layout looks like this: * $layout = [ * 'Heading 1' * ['TF1', 1, 'truefalse'], * 'Heading 2*' * ['TF2', 2, 'truefalse'], * ]; * That is, either a string, which represents a section heading, * or an array that represents a question. * * If the section heading ends with *, that section is shuffled. * * The elements in the question array are name, page number, and question type. * * @param array $layout as above. * @param array $settings optional quiz settings to override defaults. * @return quiz_settings the created quiz. */ public function create_test_quiz(array $layout, array $settings = []): quiz_settings { // Default settings - only set defaults for keys not provided by the caller. $defaults = [ 'questionsperpage' => 0, 'grade' => 100.0, 'sumgrades' => 2, 'preferredbehaviour' => 'immediatefeedback', ]; // Preserve any settings passed in, only add missing defaults. $settings += $defaults; // Create the course if needed. if (empty($settings['course'])) { $course = $this->datagenerator->create_course(); $settings['course'] = $course->id; } else { $course = get_course($settings['course']); } // Create the quiz, structure it, and return the quiz settings. $quiz = $this->create_instance($settings); $this->create_quiz_structure($quiz, $layout); $cm = get_coursemodule_from_instance('quiz', $quiz->id, $settings['course']); return new quiz_settings($quiz, $cm, $course); } /** * Parse the section name, optionally followed by a * to mean shuffle, as * used by create_test_quiz as assert_quiz_layout. * * @param string $heading the heading. * @return array with two elements, the heading and the shuffle setting. */ public function parse_section_name($heading): array { if (str_ends_with($heading, '*')) { return [substr($heading, 0, -1), 1]; } else { return [$heading, 0]; } } } PK9B\ c ,tests/generator/behat_mod_quiz_generator.phpnu[. /** * Behat data generator for mod_quiz. * * @package mod_quiz * @category test * @copyright 2019 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); /** * Behat data generator for mod_quiz. * * @copyright 2019 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class behat_mod_quiz_generator extends behat_generator_base { protected function get_creatable_entities(): array { return [ 'group overrides' => [ 'singular' => 'group override', 'datagenerator' => 'override', 'required' => ['quiz', 'group'], 'switchids' => ['quiz' => 'quiz', 'group' => 'groupid'], ], 'user overrides' => [ 'singular' => 'user override', 'datagenerator' => 'override', 'required' => ['quiz', 'user'], 'switchids' => ['quiz' => 'quiz', 'user' => 'userid'], ], 'grade items' => [ 'singular' => 'grade item', 'datagenerator' => 'grade_item', 'required' => ['quiz', 'name'], 'switchids' => ['quiz' => 'quizid'], ], ]; } /** * Look up the id of a quiz from its name. * * @param string $quizname the quiz name, for example 'Test quiz'. * @return int corresponding id. */ protected function get_quiz_id(string $quizname): int { global $DB; if (!$id = $DB->get_field('quiz', 'id', ['name' => $quizname])) { throw new Exception('There is no quiz with name "' . $quizname . '" does not exist'); } return $id; } } PK9B\AqT*T*tests/repaginate_test.phpnu[. /** * Unit tests for the {@link \mod_quiz\repaginate} class. * @package mod_quiz * @category test * @copyright 2014 The Open Univsersity * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace mod_quiz; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->dirroot . '/mod/quiz/locallib.php'); require_once($CFG->dirroot . '/mod/quiz/classes/repaginate.php'); /** * Test for {@see \mod_quiz\repaginate} * @copyright 2014 The Open Univsersity * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class mod_quiz_repaginate_testable extends repaginate { public function __construct($quizid = 0, $slots = null) { return parent::__construct($quizid, $slots); } public function get_this_slot($slots, $slotnumber) { return parent::get_this_slot($slots, $slotnumber); } public function get_slots_by_slotid($slots = null) { return parent::get_slots_by_slotid($slots); } public function get_slots_by_slot_number($slots = null) { return parent::get_slots_by_slot_number($slots); } public function repaginate_this_slot($slot, $newpagenumber) { return parent::repaginate_this_slot($slot, $newpagenumber); } public function repaginate_next_slot($nextslotnumber, $type) { return parent::repaginate_next_slot($nextslotnumber, $type); } } /** * Test for some parts of the repaginate class. * @copyright 2014 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class repaginate_test extends \advanced_testcase { /** @var array stores the slots. */ private $quizslots; /** @var mod_quiz_repaginate_testable the object being tested. */ private $repaginate = null; public function setUp(): void { parent::setUp(); $this->set_quiz_slots($this->get_quiz_object()->get_slots()); $this->repaginate = new mod_quiz_repaginate_testable(0, $this->quizslots); } /** * Create a quiz, add five questions to the quiz * which are all on one page and return the quiz object. */ private function get_quiz_object() { global $SITE; $this->resetAfterTest(true); // Make a quiz. $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz'); $quiz = $quizgenerator->create_instance([ 'course' => $SITE->id, 'questionsperpage' => 0, 'grade' => 100.0, 'sumgrades' => 2]); $cm = get_coursemodule_from_instance('quiz', $quiz->id, $SITE->id); // Create five questions. $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); $cat = $questiongenerator->create_question_category(); $shortanswer = $questiongenerator->create_question('shortanswer', null, ['category' => $cat->id]); $numerical = $questiongenerator->create_question('numerical', null, ['category' => $cat->id]); $essay = $questiongenerator->create_question('essay', null, ['category' => $cat->id]); $truefalse = $questiongenerator->create_question('truefalse', null, ['category' => $cat->id]); $match = $questiongenerator->create_question('match', null, ['category' => $cat->id]); // Add them to the quiz. quiz_add_quiz_question($shortanswer->id, $quiz); quiz_add_quiz_question($numerical->id, $quiz); quiz_add_quiz_question($essay->id, $quiz); quiz_add_quiz_question($truefalse->id, $quiz); quiz_add_quiz_question($match->id, $quiz); // Return the quiz object. $quizobj = new quiz_settings($quiz, $cm, $SITE); return structure::create_for_quiz($quizobj); } /** * Set the quiz slots * @param string $slots */ private function set_quiz_slots($slots = null) { if (!$slots) { $this->quizslots = $this->get_quiz_object()->get_slots(); } else { $this->quizslots = $slots; } } /** * Test the get_this_slot() method */ public function test_get_this_slot(): void { $this->set_quiz_slots(); $actual = []; $expected = $this->repaginate->get_slots_by_slot_number(); $this->assertEquals($expected, $actual); $slotsbyno = $this->repaginate->get_slots_by_slot_number($this->quizslots); $slotnumber = 5; $thisslot = $this->repaginate->get_this_slot($this->quizslots, $slotnumber); $this->assertEquals($slotsbyno[$slotnumber], $thisslot); } public function test_get_slots_by_slotnumber(): void { $this->set_quiz_slots(); $expected = []; $actual = $this->repaginate->get_slots_by_slot_number(); $this->assertEquals($expected, $actual); foreach ($this->quizslots as $slot) { $expected[$slot->slot] = $slot; } $actual = $this->repaginate->get_slots_by_slot_number($this->quizslots); $this->assertEquals($expected, $actual); } public function test_get_slots_by_slotid(): void { $this->set_quiz_slots(); $actual = $this->repaginate->get_slots_by_slotid(); $this->assertEquals([], $actual); $slotsbyno = $this->repaginate->get_slots_by_slot_number($this->quizslots); $actual = $this->repaginate->get_slots_by_slotid($slotsbyno); $this->assertEquals($this->quizslots, $actual); } public function test_repaginate_n_questions_per_page(): void { $this->set_quiz_slots(); // Expect 2 questions per page. $expected = []; foreach ($this->quizslots as $slot) { // Page 1 contains Slots 1 and 2. if ($slot->slot >= 1 && $slot->slot <= 2) { $slot->page = 1; } // Page 2 contains slots 3 and 4. if ($slot->slot >= 3 && $slot->slot <= 4) { $slot->page = 2; } // Page 3 contains slots 5. if ($slot->slot >= 5 && $slot->slot <= 6) { $slot->page = 3; } $expected[$slot->id] = $slot; } $actual = $this->repaginate->repaginate_n_question_per_page($this->quizslots, 2); $this->assertEquals($expected, $actual); // Expect 3 questions per page. $expected = []; foreach ($this->quizslots as $slot) { // Page 1 contains Slots 1, 2 and 3. if ($slot->slot >= 1 && $slot->slot <= 3) { $slot->page = 1; } // Page 2 contains slots 4 and 5. if ($slot->slot >= 4 && $slot->slot <= 6) { $slot->page = 2; } $expected[$slot->id] = $slot; } $actual = $this->repaginate->repaginate_n_question_per_page($this->quizslots, 3); $this->assertEquals($expected, $actual); // Expect 5 questions per page. $expected = []; foreach ($this->quizslots as $slot) { // Page 1 contains Slots 1, 2, 3, 4 and 5. if ($slot->slot > 0 && $slot->slot < 6) { $slot->page = 1; } // Page 2 contains slots 6, 7, 8, 9 and 10. if ($slot->slot > 5 && $slot->slot < 11) { $slot->page = 2; } $expected[$slot->id] = $slot; } $actual = $this->repaginate->repaginate_n_question_per_page($this->quizslots, 5); $this->assertEquals($expected, $actual); // Expect 10 questions per page. $expected = []; foreach ($this->quizslots as $slot) { // Page 1 contains Slots 1 to 10. if ($slot->slot >= 1 && $slot->slot <= 10) { $slot->page = 1; } // Page 2 contains slots 11 to 20. if ($slot->slot >= 11 && $slot->slot <= 20) { $slot->page = 2; } $expected[$slot->id] = $slot; } $actual = $this->repaginate->repaginate_n_question_per_page($this->quizslots, 10); $this->assertEquals($expected, $actual); // Expect 1 questions per page. $expected = []; $page = 1; foreach ($this->quizslots as $slot) { $slot->page = $page++; $expected[$slot->id] = $slot; } $actual = $this->repaginate->repaginate_n_question_per_page($this->quizslots, 1); $this->assertEquals($expected, $actual); } public function test_repaginate_this_slot(): void { $this->set_quiz_slots(); $slotsbyslotno = $this->repaginate->get_slots_by_slot_number($this->quizslots); $slotnumber = 3; $newpagenumber = 2; $thisslot = $slotsbyslotno[3]; $thisslot->page = $newpagenumber; $expected = $thisslot; $actual = $this->repaginate->repaginate_this_slot($slotsbyslotno[3], $newpagenumber); $this->assertEquals($expected, $actual); } public function test_repaginate_the_rest(): void { $this->set_quiz_slots(); $slotfrom = 1; $type = repaginate::LINK; $expected = []; foreach ($this->quizslots as $slot) { if ($slot->slot > $slotfrom) { $slot->page = $slot->page - 1; $expected[$slot->id] = $slot; } } $actual = $this->repaginate->repaginate_the_rest($this->quizslots, $slotfrom, $type, false); $this->assertEquals($expected, $actual); $slotfrom = 2; $newslots = []; foreach ($this->quizslots as $s) { if ($s->slot === $slotfrom) { $s->page = $s->page - 1; } $newslots[$s->id] = $s; } $type = repaginate::UNLINK; $expected = []; foreach ($this->quizslots as $slot) { if ($slot->slot > ($slotfrom - 1)) { $slot->page = $slot->page - 1; $expected[$slot->id] = $slot; } } $actual = $this->repaginate->repaginate_the_rest($newslots, $slotfrom, $type, false); $this->assertEquals($expected, $actual); } } PK9B\@'44#tests/external/grade_items_test.phpnu[. namespace mod_quiz\external; defined('MOODLE_INTERNAL') || die(); require_once(__DIR__ . '/../../../../webservice/tests/helpers.php'); use coding_exception; use core_question_generator; use externallib_advanced_testcase; use mod_quiz\quiz_attempt; use mod_quiz\quiz_settings; use required_capability_exception; use stdClass; /** * Test for the grade_items CRUD service. * * @package mod_quiz * @category external * @copyright 2023 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @covers \mod_quiz\external\create_grade_items * @covers \mod_quiz\external\delete_grade_items * @covers \mod_quiz\external\update_grade_items * @covers \mod_quiz\structure */ final class grade_items_test extends externallib_advanced_testcase { public function test_create_grade_items_service_works(): void { $quizobj = $this->create_quiz_with_two_grade_items(); create_grade_items::execute($quizobj->get_quizid(), [ ['name' => 'Speaking'], ['name' => 'Writing'], ]); $structure = $quizobj->get_structure(); $items = array_values($structure->get_grade_items()); $this->assertEquals('Listening', $items[0]->name); $this->assertEquals('Reading', $items[1]->name); $this->assertEquals('Speaking', $items[2]->name); $this->assertEquals('Writing', $items[3]->name); } public function test_create_grade_items_service_checks_permissions(): void { $quizobj = $this->create_quiz_with_two_grade_items(); $unprivilegeduser = $this->getDataGenerator()->create_user(); $this->setUser($unprivilegeduser); $this->expectException(required_capability_exception::class); create_grade_items::execute($quizobj->get_quizid(), []); } public function test_update_grade_items_service_works(): void { $quizobj = $this->create_quiz_with_two_grade_items(); $structure = $quizobj->get_structure(); $items = array_values($structure->get_grade_items()); update_grade_items::execute($quizobj->get_quizid(), [ ['id' => $items[0]->id, 'name' => 'Speaking'], ['id' => $items[1]->id, 'name' => null], ]); $structure = $quizobj->get_structure(); $updateditems = $structure->get_grade_items(); $this->assertEquals('Speaking', $updateditems[$items[0]->id]->name); $this->assertEquals($items[1]->name, $updateditems[$items[1]->id]->name); } public function test_update_grade_items_service_checks_permissions(): void { $quizobj = $this->create_quiz_with_two_grade_items(); $unprivilegeduser = $this->getDataGenerator()->create_user(); $this->setUser($unprivilegeduser); $this->expectException(required_capability_exception::class); update_grade_items::execute($quizobj->get_quizid(), []); } public function test_delete_grade_items_service_works(): void { $quizobj = $this->create_quiz_with_two_grade_items(); $structure = $quizobj->get_structure(); $items = array_values($structure->get_grade_items()); $structure->update_slot_grade_item($structure->get_slot_by_number(1), null); $structure->update_slot_grade_item($structure->get_slot_by_number(2), null); delete_grade_items::execute($quizobj->get_quizid(), [['id' => $items[0]->id]]); $structure = $quizobj->get_structure(); $updateditems = $structure->get_grade_items(); $this->assertCount(1, $updateditems); $this->assertEquals('Reading', $updateditems[$items[1]->id]->name); } public function test_cant_delete_grade_item_that_is_used(): void { $quizobj = $this->create_quiz_with_two_grade_items(); $structure = $quizobj->get_structure(); $items = array_values($structure->get_grade_items()); $this->expectException(coding_exception::class); delete_grade_items::execute($quizobj->get_quizid(), [['id' => $items[0]->id]]); } public function test_delete_grade_items_service_checks_permissions(): void { $quizobj = $this->create_quiz_with_two_grade_items(); $unprivilegeduser = $this->getDataGenerator()->create_user(); $this->setUser($unprivilegeduser); $structure = $quizobj->get_structure(); $items = array_values($structure->get_grade_items()); $this->expectException(required_capability_exception::class); delete_grade_items::execute($quizobj->get_quizid(), [['id' => $items[0]->id]]); } public function test_get_edit_grading_page_data_service_works(): void { global $PAGE; $PAGE->set_url('/'); $quizobj = $this->create_quiz_with_two_grade_items(); $jsondata = get_edit_grading_page_data::execute($quizobj->get_quizid()); $this->assertJson($jsondata); $data = json_decode($jsondata); $this->assertEquals($quizobj->get_quizid(), $data->quizid); } public function test_get_edit_grading_page_data_service_checks_permissions(): void { $quizobj = $this->create_quiz_with_two_grade_items(); $unprivilegeduser = $this->getDataGenerator()->create_user(); $this->setUser($unprivilegeduser); $this->expectException(required_capability_exception::class); get_edit_grading_page_data::execute($quizobj->get_quizid()); } /** * Create a quiz of two shortanswer questions, each contributing to a different grade item. * * @return quiz_settings the newly created quiz. */ protected function create_quiz_with_two_grade_items(): quiz_settings { global $SITE; $this->resetAfterTest(); $this->setAdminUser(); // Make a quiz. /** @var \mod_quiz_generator $quizgenerator */ $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz'); $quiz = $quizgenerator->create_instance(['course' => $SITE->id]); // Create two question. /** @var core_question_generator $questiongenerator */ $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); $cat = $questiongenerator->create_question_category(); $saq1 = $questiongenerator->create_question('shortanswer', null, ['category' => $cat->id]); $saq2 = $questiongenerator->create_question('shortanswer', null, ['category' => $cat->id]); // Add them to the quiz. quiz_add_quiz_question($saq1->id, $quiz, 0, 1); quiz_add_quiz_question($saq2->id, $quiz, 0, 1); // Create two quiz grade items. $listeninggrade = $quizgenerator->create_grade_item(['quizid' => $quiz->id, 'name' => 'Listening']); $readinggrade = $quizgenerator->create_grade_item(['quizid' => $quiz->id, 'name' => 'Reading']); // Set the questions to use those grade items. $quizobj = quiz_settings::create($quiz->id); $structure = $quizobj->get_structure(); $structure->update_slot_grade_item($structure->get_slot_by_number(1), $listeninggrade->id); $structure->update_slot_grade_item($structure->get_slot_by_number(2), $readinggrade->id); $quizobj->get_grade_calculator()->recompute_quiz_sumgrades(); return $quizobj; } public function test_create_grade_item_per_section_works(): void { global $SITE; $this->resetAfterTest(); $this->setAdminUser(); // Create a quiz with no grade items yet, but two sections. /** @var \mod_quiz_generator $quizgenerator */ $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz'); $quiz = $quizgenerator->create_instance(['course' => $SITE->id]); // Create three questions. /** @var core_question_generator $questiongenerator */ $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); $cat = $questiongenerator->create_question_category(); $saq1 = $questiongenerator->create_question('shortanswer', null, ['category' => $cat->id]); $saq2 = $questiongenerator->create_question('shortanswer', null, ['category' => $cat->id]); $saq3 = $questiongenerator->create_question('shortanswer', null, ['category' => $cat->id]); // Add them to the quiz. quiz_add_quiz_question($saq1->id, $quiz, 1, 1); quiz_add_quiz_question($saq2->id, $quiz, 1, 1); quiz_add_quiz_question($saq3->id, $quiz, 2, 1); // Create two sections. $quizobj = quiz_settings::create($quiz->id); $structure = $quizobj->get_structure(); $defaultsection = array_values($structure->get_sections())[0]; $structure->set_section_heading($defaultsection->id, 'Listening'); $structure->add_section_heading(2, 'Reading'); // Call the method we are testing. create_grade_item_per_section::execute($quizobj->get_quizid()); // Verify. $structure = $quizobj->get_structure(); $gradeitems = array_values($structure->get_grade_items()); $this->assertCount(2, $gradeitems); $this->assertEquals('Listening', $gradeitems[0]->name); $this->assertEquals(1, $gradeitems[0]->sortorder); $this->assertEquals('Reading', $gradeitems[1]->name); $this->assertEquals(2, $gradeitems[1]->sortorder); $this->assertEquals($gradeitems[0]->id, $structure->get_slot_by_number(1)->quizgradeitemid); $this->assertEquals($gradeitems[0]->id, $structure->get_slot_by_number(2)->quizgradeitemid); $this->assertEquals($gradeitems[1]->id, $structure->get_slot_by_number(3)->quizgradeitemid); } public function test_create_grade_item_per_section_with_descriptions(): void { global $SITE; $this->resetAfterTest(); $this->setAdminUser(); // Create a quiz with no grade items yet, but two sections. /** @var \mod_quiz_generator $quizgenerator */ $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz'); $quiz = $quizgenerator->create_instance(['course' => $SITE->id]); // Create three questions. /** @var core_question_generator $questiongenerator */ $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); $cat = $questiongenerator->create_question_category(); $desc1 = $questiongenerator->create_question('description', null, ['category' => $cat->id]); $desc2 = $questiongenerator->create_question('description', null, ['category' => $cat->id]); $saq1 = $questiongenerator->create_question('shortanswer', null, ['category' => $cat->id]); // Add them to the quiz. quiz_add_quiz_question($desc1->id, $quiz, 1); quiz_add_quiz_question($desc2->id, $quiz, 2); quiz_add_quiz_question($saq1->id, $quiz, 2, 7); // Create two sections. $quizobj = quiz_settings::create($quiz->id); $structure = $quizobj->get_structure(); $defaultsection = array_values($structure->get_sections())[0]; $structure->set_section_heading($defaultsection->id, 'Introduction'); $structure->add_section_heading(2, 'The question'); // Call the method we are testing. create_grade_item_per_section::execute($quizobj->get_quizid()); // Verify. $structure = $quizobj->get_structure(); $gradeitems = array_values($structure->get_grade_items()); $this->assertCount(1, $gradeitems); $this->assertEquals('The question', $gradeitems[0]->name); $this->assertEquals(1, $gradeitems[0]->sortorder); $this->assertNull($structure->get_slot_by_number(1)->quizgradeitemid); $this->assertNull($structure->get_slot_by_number(2)->quizgradeitemid); $this->assertEquals($gradeitems[0]->id, $structure->get_slot_by_number(3)->quizgradeitemid); } public function test_create_grade_item_per_section_service_checks_permissions(): void { global $SITE; $this->resetAfterTest(); // Create a quiz. /** @var \mod_quiz_generator $quizgenerator */ $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz'); $quiz = $quizgenerator->create_instance(['course' => $SITE->id]); $unprivilegeduser = $this->getDataGenerator()->create_user(); $this->setUser($unprivilegeduser); $this->expectException(required_capability_exception::class); create_grade_item_per_section::execute($quiz->id); } public function test_cant_create_grade_item_per_section_if_grade_items_already_exist(): void { $quizobj = $this->create_quiz_with_two_grade_items(); $this->expectException(coding_exception::class); create_grade_item_per_section::execute($quizobj->get_quizid()); } } PK9B\5U!gg/tests/external/update_filter_condition_test.phpnu[. namespace mod_quiz\external; use advanced_testcase; use core_question\local\bank\condition; use mod_quiz\quiz_settings; /** * Unit tests for the update_filter_condition webservice. * * @package mod_quiz * @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 \mod_quiz\external\update_filter_condition::execute */ final class update_filter_condition_test extends advanced_testcase { /** * Generate a course with a quiz activity with two random questions for use in tests. * @return array */ private function create_quiz_with_random_questions(): array { $this->resetAfterTest(); $this->setAdminUser(); // Create a course. $course = $this->getDataGenerator()->create_course(); // Create a quiz activity. $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz'); $quiz = $quizgenerator->create_instance(['course' => $course->id]); $cm = get_coursemodule_from_instance('quiz', $quiz->id); $quizobj = quiz_settings::create($quiz->id); $quizcontext = \core\context\module::instance($quiz->cmid); // Create a question category (top level) and question. $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); $questioncategory = $questiongenerator->create_question_category(); // Create 2 questions to choose from. $questiongenerator->create_question('shortanswer', null, ['category' => $questioncategory->id]); $questiongenerator->create_question('shortanswer', null, ['category' => $questioncategory->id]); // Add random question to the quiz. $filtercondition = [ 'filter' => [ 'category' => [ 'jointype' => condition::JOINTYPE_DEFAULT, 'values' => [$questioncategory->id], 'filteroptions' => ['includesubcategories' => false], ], ], ]; $quizobj->get_structure()->add_random_questions(1, 1, $filtercondition); return [ $course, $cm, $questioncategory, $filtercondition, $quizcontext, ]; } /** * Test updating the filter conditions of a random question * @runInSeparateProcess * @return void */ public function test_update_filter_condition(): void { global $DB; [ $course, $cm, $questioncategory, $filtercondition, $quizcontext, ] = $this->create_quiz_with_random_questions(); $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); // Check what the question set reference record contains currently. $qsetref = $DB->get_record('question_set_references', [ 'usingcontextid' => $quizcontext->id, ]); $this->assertEquals($questioncategory->contextid, $qsetref->questionscontextid); // Create a new question category on the course. $coursecontext = \core\context\course::instance($course->id); $questioncategory2 = $questiongenerator->create_question_category([ 'contextid' => $coursecontext->id, 'parent' => 0, ]); // Call the webservice execute method. $filtercondition['filter']['category']['values'] = [$questioncategory2->id]; update_filter_condition::execute($cm->id, $qsetref->itemid, json_encode($filtercondition)); // Check that the questionscontextid value has been updated. $qsetref = $DB->get_record('question_set_references', [ 'usingcontextid' => $quizcontext->id, ]); $this->assertEquals($questioncategory2->contextid, $qsetref->questionscontextid); } /** * Test updating the filter conditions of a random question with an invalid array of conditions * @runInSeparateProcess * @return void */ public function test_invalid_filter_condition(): void { global $DB; [, $cm, , , $quizcontext] = $this->create_quiz_with_random_questions(); $qsetref = $DB->get_record('question_set_references', [ 'usingcontextid' => $quizcontext->id, ]); // Try to call the service with an invalid array of filterconditions. $filtercondition = ['invalid' => true]; $this->expectException(\moodle_exception::class); update_filter_condition::execute($cm->id, $qsetref->itemid, json_encode($filtercondition)); } /** * Test updating the filter conditions of a random question with an invalid question category * @runInSeparateProcess * @return void */ public function test_invalid_question_category(): void { global $DB; [ , $cm, , $filtercondition, $quizcontext] = $this->create_quiz_with_random_questions(); $qsetref = $DB->get_record('question_set_references', [ 'usingcontextid' => $quizcontext->id, ]); // Try to call the service with an invalid question category set in the $filtercondition. $filtercondition['filter']['category']['values'] = [123]; $this->expectException(\moodle_exception::class); update_filter_condition::execute($cm->id, $qsetref->itemid, json_encode($filtercondition)); } } PK9B\=&tests/external/reopen_attempt_test.phpnu[. namespace mod_quiz\external; defined('MOODLE_INTERNAL') || die(); require_once(__DIR__ . '/../../../../webservice/tests/helpers.php'); use coding_exception; use core_question_generator; use externallib_advanced_testcase; use mod_quiz\quiz_attempt; use mod_quiz\quiz_settings; use required_capability_exception; use stdClass; /** * Test for the reopen_attempt and get_reopen_attempt_confirmation services. * * @package mod_quiz * @category external * @copyright 2023 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @covers \mod_quiz\external\reopen_attempt * @covers \mod_quiz\external\get_reopen_attempt_confirmation */ final class reopen_attempt_test extends externallib_advanced_testcase { /** @var stdClass|null if we make a quiz attempt, we store the student object here. */ protected $student; public function test_reopen_attempt_service_works(): void { [$attemptid] = $this->create_attempt_at_quiz_with_one_shortanswer_question(); reopen_attempt::execute($attemptid); $attemptobj = quiz_attempt::create($attemptid); $this->assertEquals(quiz_attempt::IN_PROGRESS, $attemptobj->get_state()); } public function test_reopen_attempt_service_checks_permissions(): void { [$attemptid] = $this->create_attempt_at_quiz_with_one_shortanswer_question(); $unprivilegeduser = $this->getDataGenerator()->create_user(); $this->setUser($unprivilegeduser); $this->expectException(required_capability_exception::class); reopen_attempt::execute($attemptid); } public function test_reopen_attempt_service_checks_attempt_state(): void { [$attemptid] = $this->create_attempt_at_quiz_with_one_shortanswer_question(quiz_attempt::IN_PROGRESS); $this->expectExceptionMessage("Attempt $attemptid is in the wrong state (In progress) to be reopened."); reopen_attempt::execute($attemptid); } public function test_get_reopen_attempt_confirmation_staying_open(): void { global $DB; [$attemptid, $quizid] = $this->create_attempt_at_quiz_with_one_shortanswer_question(); $DB->set_field('quiz', 'timeclose', 0, ['id' => $quizid]); $message = get_reopen_attempt_confirmation::execute($attemptid); $this->assertEquals('

                This will reopen attempt 1 by ' . fullname($this->student) . '.

                The attempt will remain open and can be continued.

                ', $message); } public function test_get_reopen_attempt_confirmation_staying_open_until(): void { global $DB; [$attemptid, $quizid] = $this->create_attempt_at_quiz_with_one_shortanswer_question(); $timeclose = time() + HOURSECS; $DB->set_field('quiz', 'timeclose', $timeclose, ['id' => $quizid]); $message = get_reopen_attempt_confirmation::execute($attemptid); $this->assertEquals('

                This will reopen attempt 1 by ' . fullname($this->student) . '.

                The attempt will remain open and can be continued until the quiz closes on ' . userdate($timeclose) . '.

                ', $message); } public function test_get_reopen_attempt_confirmation_submitting(): void { global $DB; [$attemptid, $quizid] = $this->create_attempt_at_quiz_with_one_shortanswer_question(); $timeclose = time() - HOURSECS; $DB->set_field('quiz', 'timeclose', $timeclose, ['id' => $quizid]); $message = get_reopen_attempt_confirmation::execute($attemptid); $this->assertEquals('

                This will reopen attempt 1 by ' . fullname($this->student) . '.

                The attempt will be immediately submitted for grading.

                ', $message); } public function test_get_reopen_attempt_confirmation_service_checks_permissions(): void { [$attemptid] = $this->create_attempt_at_quiz_with_one_shortanswer_question(); $unprivilegeduser = $this->getDataGenerator()->create_user(); $this->setUser($unprivilegeduser); $this->expectException(required_capability_exception::class); get_reopen_attempt_confirmation::execute($attemptid); } public function test_get_reopen_attempt_confirmation_service_checks_attempt_state(): void { [$attemptid] = $this->create_attempt_at_quiz_with_one_shortanswer_question(quiz_attempt::IN_PROGRESS); $this->expectExceptionMessage("Attempt $attemptid is in the wrong state (In progress) to be reopened."); get_reopen_attempt_confirmation::execute($attemptid); } /** * Create a quiz of one shortanswer question and an attempt in a given state. * * @param string $attemptstate the desired attempt state. quiz_attempt::ABANDONED or ::IN_PROGRESS. * @return array with two elements, the attempt id and the quiz id. */ protected function create_attempt_at_quiz_with_one_shortanswer_question( string $attemptstate = quiz_attempt::ABANDONED ): array { global $SITE; $this->resetAfterTest(); // Make a quiz. $timeclose = time() + HOURSECS; $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz'); $quiz = $quizgenerator->create_instance([ 'course' => $SITE->id, 'timeclose' => $timeclose, 'overduehandling' => 'autoabandon' ]); // Create a question. /** @var core_question_generator $questiongenerator */ $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); $cat = $questiongenerator->create_question_category(); $saq = $questiongenerator->create_question('shortanswer', null, ['category' => $cat->id]); // Add them to the quiz. $quizobj = quiz_settings::create($quiz->id); quiz_add_quiz_question($saq->id, $quiz, 0, 1); $quizobj->get_grade_calculator()->recompute_quiz_sumgrades(); // Make a user to do the quiz. $this->student = $this->getDataGenerator()->create_user(); $this->setUser($this->student); $quizobj = quiz_settings::create($quiz->id, $this->student->id); // Start the attempt. $attempt = quiz_prepare_and_start_new_attempt($quizobj, 1, null); $attemptobj = quiz_attempt::create($attempt->id); if ($attemptstate === quiz_attempt::ABANDONED) { // Attempt goes overdue (e.g. if cron ran). $attemptobj->process_abandon($timeclose + 2 * get_config('quiz', 'graceperiodmin'), false); } else if ($attemptstate !== quiz_attempt::IN_PROGRESS) { throw new coding_exception('Status ' . $attemptstate . ' not currently supported.'); } // Set current user to admin before we return. $this->setAdminUser(); return [$attemptobj->get_attemptid(), $attemptobj->get_quizid()]; } } PK9B\p$tests/external/update_slots_test.phpnu[. namespace mod_quiz\external; defined('MOODLE_INTERNAL') || die(); require_once(__DIR__ . '/../../../../webservice/tests/helpers.php'); use core_question_generator; use externallib_advanced_testcase; use mod_quiz\quiz_settings; use required_capability_exception; /** * Test for the update_slots service. * * @package mod_quiz * @category external * @copyright 2023 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @covers \mod_quiz\external\update_slots */ final class update_slots_test extends externallib_advanced_testcase { public function test_update_slots_service_works(): void { global $DB; $quizobj = $this->create_quiz_with_two_shortanswer_questions(); $this->setAdminUser(); $structure = $quizobj->get_structure(); // No changes to slot 1. $slot1data = [ 'id' => $structure->get_slot_by_number(1)->id, ]; // Change everything in slot 2. $slot2data = [ 'id' => $structure->get_slot_by_number(2)->id, 'displaynumber' => '1b', 'requireprevious' => true, 'maxmark' => 7, 'quizgradeitemid' => 123, ]; update_slots::execute($quizobj->get_quizid(), [$slot1data, $slot2data]); $slot = $DB->get_record('quiz_slots', ['id' => $slot2data['id']]); $this->assertEquals('1b', $slot->displaynumber); $this->assertTrue((bool) $slot->requireprevious); $this->assertEquals(7, $slot->maxmark); $this->assertEquals(123, $slot->quizgradeitemid); } public function test_update_slots_checks_permissions(): void { $quizobj = $this->create_quiz_with_two_shortanswer_questions(); $unprivilegeduser = $this->getDataGenerator()->create_user(); $this->setUser($unprivilegeduser); $this->expectException(required_capability_exception::class); update_slots::execute($quizobj->get_quizid(), []); } /** * Create a quiz of two shortanswer questions. * * @return quiz_settings the newly created quiz. */ protected function create_quiz_with_two_shortanswer_questions(): quiz_settings { global $SITE; $this->resetAfterTest(); // Make a quiz. $timeclose = time() + HOURSECS; $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz'); $quiz = $quizgenerator->create_instance([ 'course' => $SITE->id, 'timeclose' => $timeclose, 'overduehandling' => 'autoabandon', ]); // Create a question. /** @var core_question_generator $questiongenerator */ $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); $cat = $questiongenerator->create_question_category(); $saq1 = $questiongenerator->create_question('shortanswer', null, ['category' => $cat->id]); $saq2 = $questiongenerator->create_question('shortanswer', null, ['category' => $cat->id]); // Add them to the quiz. $quizobj = quiz_settings::create($quiz->id); quiz_add_quiz_question($saq1->id, $quiz, 0, 1); quiz_add_quiz_question($saq2->id, $quiz, 0, 1); $quizobj->get_grade_calculator()->recompute_quiz_sumgrades(); return $quizobj; } } PK9B\D?? tests/external/override_test.phpnu[. namespace mod_quiz\external; defined('MOODLE_INTERNAL') || die(); require_once(__DIR__ . '/../../../../webservice/tests/helpers.php'); /** * Tests for override webservices * * @package mod_quiz * @copyright 2024 Matthew Hilton * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @covers \mod_quiz\external\get_overrides * @covers \mod_quiz\external\save_overrides * @covers \mod_quiz\external\delete_overrides */ final class override_test extends \externallib_advanced_testcase { /** * Creates a quiz for testing. * * @return object $quiz */ private function create_quiz(): object { $course = $this->getDataGenerator()->create_course(); return $this->getDataGenerator()->create_module('quiz', ['course' => $course->id]); } /** * Provides values to test_get_overrides * * @return array */ public static function get_override_provider(): array { return [ 'quiz that exists' => [ 'quizid' => ':quizid', ], 'quiz that does not exist' => [ 'quizid' => -1, 'expectedexception' => \dml_missing_record_exception::class, ], ]; } /** * Tests get_overrides * * @param int|string $quizid * @param string $expectedexception * @dataProvider get_override_provider */ public function test_get_overrides(int|string $quizid, string $expectedexception = ''): void { global $DB; $this->resetAfterTest(); $this->setAdminUser(); $quiz = $this->create_quiz(); // Create an override. $DB->insert_record('quiz_overrides', ['quiz' => $quiz->id]); // Replace placeholders. if ($quizid == ":quizid") { $quizid = $quiz->id; } if (!empty($expectedexception)) { $this->expectException($expectedexception); } $result = get_overrides::execute($quizid); $this->assertNotEmpty($result); } /** * Provides values to test_save_overrides * * @return array */ public static function save_overrides_provider(): array { return [ 'good insert' => [ 'data' => [ 'timeopen' => 999, ], ], 'bad insert' => [ 'data' => [ 'id' => ':existingid', 'timeopen' => -1, ], 'expectedexception' => \invalid_parameter_exception::class, ], 'good update' => [ 'data' => [ 'timeopen' => 999, ], ], 'bad update' => [ 'data' => [ 'id' => ':existingid', 'timeopen' => -1, ], 'expectedexception' => \invalid_parameter_exception::class, ], ]; } /** * Tests save_overrides * * @dataProvider save_overrides_provider * @param array $data * @param string $expectedexception */ public function test_save_overrides(array $data, string $expectedexception = ''): void { global $DB; $this->resetAfterTest(); $this->setAdminUser(); $quiz = $this->create_quiz(); $user = $this->getDataGenerator()->create_user(); if (!empty($data['id'])) { $data['id'] = $DB->insert_record('quiz_overrides', ['quiz' => $quiz->id, 'userid' => $user->id]); } // Make a new user to insert a new override for. $user = $this->getDataGenerator()->create_user(); $data = array_merge($data, ['userid' => $user->id]); $payload = [ 'quizid' => $quiz->id, 'overrides' => [ $data, ], ]; if (!empty($expectedexception)) { $this->expectException($expectedexception); } $result = save_overrides::execute($payload); // If has reached here, but not thrown exception and was expected to, fail the test. if ($expectedexception) { $this->fail("Expected exception " . $expectedexception . " was not thrown"); } $this->assertNotEmpty($result['ids']); $this->assertCount(1, $result['ids']); } /** * Provides values to test_delete_overrides * * @return array */ public static function delete_overrides_provider(): array { return [ 'delete existing override' => [ 'id' => ':existingid', ], 'delete override that does not exist' => [ 'id' => -1, 'expectedexception' => \invalid_parameter_exception::class, ], ]; } /** * Tests delete_overrides * * @dataProvider delete_overrides_provider * @param int|string $id * @param string $expectedexception */ public function test_delete_overrides(int|string $id, string $expectedexception = ''): void { global $DB; $this->resetAfterTest(); $this->setAdminUser(); $quiz = $this->create_quiz(); $user = $this->getDataGenerator()->create_user(); if ($id == ':existingid') { $id = $DB->insert_record('quiz_overrides', ['quiz' => $quiz->id, 'userid' => $user->id]); } if (!empty($expectedexception)) { $this->expectException($expectedexception); } $result = delete_overrides::execute(['quizid' => $quiz->id, 'ids' => [$id]]); // If has reached here, but not thrown exception and was expected to, fail the test. if ($expectedexception) { $this->fail("Expected exception " . $expectedexception . " was not thrown"); } $this->assertNotEmpty($result['ids']); $this->assertContains($id, $result['ids']); } } PK9B\m>= tests/external/external_test.phpnu[. /** * Quiz module external functions tests. * * @package mod_quiz * @category external * @copyright 2016 Juan Leyva * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @since Moodle 3.1 */ namespace mod_quiz\external; use core_external\external_api; use core_question\local\bank\question_version_status; use externallib_advanced_testcase; use mod_quiz\question\display_options; use mod_quiz\quiz_attempt; use mod_quiz\quiz_settings; use mod_quiz\structure; use mod_quiz_external; use moodle_exception; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->dirroot . '/webservice/tests/helpers.php'); require_once($CFG->dirroot . '/mod/quiz/tests/quiz_question_helper_test_trait.php'); /** * Silly class to access mod_quiz_external internal methods. * * @package mod_quiz * @copyright 2016 Juan Leyva * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @since Moodle 3.1 */ class testable_mod_quiz_external extends mod_quiz_external { /** * Public accessor. * * @param array $params Array of parameters including the attemptid and preflight data * @param bool $checkaccessrules whether to check the quiz access rules or not * @param bool $failifoverdue whether to return error if the attempt is overdue * @return array containing the attempt object and access messages */ public static function validate_attempt($params, $checkaccessrules = true, $failifoverdue = true) { return parent::validate_attempt($params, $checkaccessrules, $failifoverdue); } /** * Public accessor. * * @param array $params Array of parameters including the attemptid * @return array containing the attempt object and display options */ public static function validate_attempt_review($params) { return parent::validate_attempt_review($params); } } /** * Quiz module external functions tests * * @package mod_quiz * @category external * @copyright 2016 Juan Leyva * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @since Moodle 3.1 * @covers \mod_quiz_external */ final class external_test extends externallib_advanced_testcase { use \quiz_question_helper_test_trait; /** @var \stdClass course record. */ protected $course; /** @var \stdClass activity record. */ protected $quiz; /** @var \context_module context instance. */ protected $context; /** @var \stdClass */ protected $cm; /** @var \stdClass user record. */ protected $student; /** @var \stdClass user record. */ protected $teacher; /** @var \stdClass user role record. */ protected $studentrole; /** @var \stdClass user role record. */ protected $teacherrole; /** * 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(); $this->quiz = $this->getDataGenerator()->create_module('quiz', ['course' => $this->course->id]); $this->context = \context_module::instance($this->quiz->cmid); $this->cm = get_coursemodule_from_instance('quiz', $this->quiz->id); // Create users. $this->student = self::getDataGenerator()->create_user(); $this->teacher = self::getDataGenerator()->create_user(); // Users enrolments. $this->studentrole = $DB->get_record('role', ['shortname' => 'student']); $this->teacherrole = $DB->get_record('role', ['shortname' => 'editingteacher']); // Allow student to receive messages. $coursecontext = \context_course::instance($this->course->id); assign_capability('mod/quiz:emailnotifysubmission', CAP_ALLOW, $this->teacherrole->id, $coursecontext, true); $this->getDataGenerator()->enrol_user($this->student->id, $this->course->id, $this->studentrole->id, 'manual'); $this->getDataGenerator()->enrol_user($this->teacher->id, $this->course->id, $this->teacherrole->id, 'manual'); } /** * Create a quiz with questions including a started or finished attempt optionally * * @param boolean $startattempt whether to start a new attempt * @param boolean $finishattempt whether to finish the new attempt * @param string $behaviour the quiz preferredbehaviour, defaults to 'deferredfeedback'. * @param boolean $includeqattachments whether to include a question that supports attachments, defaults to false. * @param array $extraoptions extra options for Quiz. * @return array array containing the quiz, context and the attempt */ private function create_quiz_with_questions($startattempt = false, $finishattempt = false, $behaviour = 'deferredfeedback', $includeqattachments = false, $extraoptions = []) { // Create a new quiz with attempts. $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz'); $data = ['course' => $this->course->id, 'sumgrades' => 2, 'preferredbehaviour' => $behaviour]; $data = array_merge($data, $extraoptions); $quiz = $quizgenerator->create_instance($data); $context = \context_module::instance($quiz->cmid); // Create a couple of questions. $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); $cat = $questiongenerator->create_question_category(); $question = $questiongenerator->create_question('numerical', null, ['category' => $cat->id]); quiz_add_quiz_question($question->id, $quiz); $question = $questiongenerator->create_question('numerical', null, ['category' => $cat->id]); quiz_add_quiz_question($question->id, $quiz); if ($includeqattachments) { $question = $questiongenerator->create_question('essay', null, ['category' => $cat->id, 'attachments' => 1, 'attachmentsrequired' => 1]); quiz_add_quiz_question($question->id, $quiz); } $quizobj = quiz_settings::create($quiz->id, $this->student->id); // Set grade to pass. $item = \grade_item::fetch(['courseid' => $this->course->id, 'itemtype' => 'mod', 'itemmodule' => 'quiz', 'iteminstance' => $quiz->id, 'outcomeid' => null]); $item->gradepass = 80; $item->update(); if ($startattempt or $finishattempt) { // Now, do one attempt. $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->student->id); quiz_start_new_attempt($quizobj, $quba, $attempt, 1, $timenow); quiz_attempt_save_started($quizobj, $quba, $attempt); $attemptobj = quiz_attempt::create($attempt->id); if ($finishattempt) { // Process some responses from the student. $tosubmit = [1 => ['answer' => '3.14']]; $attemptobj->process_submitted_actions(time(), false, $tosubmit); // Finish the attempt. $attemptobj->process_submit(time(), false); $attemptobj->process_grade_submission(time()); } return [$quiz, $context, $quizobj, $attempt, $attemptobj, $quba]; } else { return [$quiz, $context, $quizobj]; } } /* * Test get quizzes by courses */ public function test_mod_quiz_get_quizzes_by_courses(): void { global $DB; // Create additional course. $course2 = self::getDataGenerator()->create_course(); // Second quiz. $record = new \stdClass(); $record->course = $course2->id; $record->intro = ''; $timeopen = time() - 1; $record->timeopen = $timeopen; $record->precreateattempts = 1; $quiz2 = self::getDataGenerator()->create_module('quiz', $record); // Execute real Moodle enrolment as we'll call unenrol() method on the instance later. $enrol = enrol_get_plugin('manual'); $enrolinstances = enrol_get_instances($course2->id, true); foreach ($enrolinstances as $courseenrolinstance) { if ($courseenrolinstance->enrol == "manual") { $instance2 = $courseenrolinstance; break; } } // Clear static cache and call get_fast_modinfo() again so that in the following cache should not be rebuilt. \course_modinfo::clear_instance_cache(); get_fast_modinfo($record->course); $enrol->enrol_user($instance2, $this->student->id, $this->studentrole->id); self::setUser($this->student); $returndescription = mod_quiz_external::get_quizzes_by_courses_returns(); // Create what we expect to be returned when querying the two courses. // First for the student user. $allusersfields = ['id', 'coursemodule', 'course', 'name', 'intro', 'introformat', 'introfiles', 'lang', 'timeopen', 'timeclose', 'grademethod', 'section', 'visible', 'groupmode', 'groupingid', 'attempts', 'timelimit', 'grademethod', 'decimalpoints', 'questiondecimalpoints', 'sumgrades', 'grade', 'preferredbehaviour', 'hasfeedback']; $userswithaccessfields = ['attemptonlast', 'reviewattempt', 'reviewcorrectness', 'reviewmaxmarks', 'reviewmarks', 'reviewspecificfeedback', 'reviewgeneralfeedback', 'reviewrightanswer', 'reviewoverallfeedback', 'questionsperpage', 'navmethod', 'browsersecurity', 'delay1', 'delay2', 'showuserpicture', 'showblocks', 'completionattemptsexhausted', 'completionpass', 'autosaveperiod', 'hasquestions', 'overduehandling', 'graceperiod', 'canredoquestions', 'allowofflineattempts']; $managerfields = ['shuffleanswers', 'timecreated', 'timemodified', 'password', 'subnet', 'precreateattempts']; // Add expected coursemodule and other data. $quiz1 = $this->quiz; $quiz1->coursemodule = $quiz1->cmid; $quiz1->introformat = 1; $quiz1->section = 0; $quiz1->visible = true; $quiz1->groupmode = 0; $quiz1->groupingid = 0; $quiz1->hasquestions = 0; $quiz1->hasfeedback = 0; $quiz1->completionpass = 0; $quiz1->autosaveperiod = get_config('quiz', 'autosaveperiod'); $quiz1->introfiles = []; $quiz1->lang = ''; $quiz2->coursemodule = $quiz2->cmid; $quiz2->introformat = 1; $quiz2->section = 0; $quiz2->visible = true; $quiz2->groupmode = 0; $quiz2->groupingid = 0; $quiz2->hasquestions = 0; $quiz2->hasfeedback = 0; $quiz2->completionpass = 0; $quiz2->autosaveperiod = get_config('quiz', 'autosaveperiod'); $quiz2->introfiles = []; $quiz2->lang = ''; foreach (array_merge($allusersfields, $userswithaccessfields) as $field) { $expected1[$field] = $quiz1->{$field}; $expected2[$field] = $quiz2->{$field}; } $expectedquizzes = [$expected2, $expected1]; // Call the external function passing course ids. $result = mod_quiz_external::get_quizzes_by_courses([$course2->id, $this->course->id]); $result = external_api::clean_returnvalue($returndescription, $result); $this->assertEquals($expectedquizzes, $result['quizzes']); $this->assertCount(0, $result['warnings']); // Call the external function without passing course id. $result = mod_quiz_external::get_quizzes_by_courses(); $result = external_api::clean_returnvalue($returndescription, $result); $this->assertEquals($expectedquizzes, $result['quizzes']); $this->assertCount(0, $result['warnings']); // Unenrol user from second course and alter expected quizzes. $enrol->unenrol_user($instance2, $this->student->id); array_shift($expectedquizzes); // Call the external function without passing course id. $result = mod_quiz_external::get_quizzes_by_courses(); $result = external_api::clean_returnvalue($returndescription, $result); $this->assertEquals($expectedquizzes, $result['quizzes']); // Call for the second course we unenrolled the user from, expected warning. $result = mod_quiz_external::get_quizzes_by_courses([$course2->id]); $this->assertCount(1, $result['warnings']); $this->assertEquals('1', $result['warnings'][0]['warningcode']); $this->assertEquals($course2->id, $result['warnings'][0]['itemid']); // Now, try as a teacher for getting all the additional fields. self::setUser($this->teacher); foreach ($managerfields as $field) { $expectedquizzes[0][$field] = $quiz1->{$field}; } $result = mod_quiz_external::get_quizzes_by_courses(); $result = external_api::clean_returnvalue($returndescription, $result); $this->assertEquals($expectedquizzes, $result['quizzes']); // Admin also should get all the information. self::setAdminUser(); $result = mod_quiz_external::get_quizzes_by_courses([$this->course->id]); $result = external_api::clean_returnvalue($returndescription, $result); $this->assertEquals($expectedquizzes, $result['quizzes']); // Now, prevent access. $enrol->enrol_user($instance2, $this->student->id); self::setUser($this->student); $quiz2->timeclose = time() - DAYSECS; $DB->update_record('quiz', $quiz2); $result = mod_quiz_external::get_quizzes_by_courses(); $result = external_api::clean_returnvalue($returndescription, $result); $this->assertCount(2, $result['quizzes']); // We only see a limited set of fields. $this->assertCount(5, $result['quizzes'][0]); $this->assertEquals($quiz2->id, $result['quizzes'][0]['id']); $this->assertEquals($quiz2->cmid, $result['quizzes'][0]['coursemodule']); $this->assertEquals($quiz2->course, $result['quizzes'][0]['course']); $this->assertEquals($quiz2->name, $result['quizzes'][0]['name']); $this->assertEquals($quiz2->course, $result['quizzes'][0]['course']); $this->assertFalse(isset($result['quizzes'][0]['timelimit'])); } /** * Test test_view_quiz */ public function test_view_quiz(): void { global $DB; // Test invalid instance id. try { mod_quiz_external::view_quiz(0); $this->fail('Exception expected due to invalid mod_quiz instance id.'); } catch (moodle_exception $e) { $this->assertEquals('invalidrecord', $e->errorcode); } // Test not-enrolled user. $usernotenrolled = self::getDataGenerator()->create_user(); $this->setUser($usernotenrolled); try { mod_quiz_external::view_quiz($this->quiz->id); $this->fail('Exception expected due to not enrolled user.'); } catch (moodle_exception $e) { $this->assertEquals('requireloginerror', $e->errorcode); } // Test user with full capabilities. $this->setUser($this->student); // Trigger and capture the event. $sink = $this->redirectEvents(); $result = mod_quiz_external::view_quiz($this->quiz->id); $result = external_api::clean_returnvalue(mod_quiz_external::view_quiz_returns(), $result); $this->assertTrue($result['status']); $events = $sink->get_events(); $this->assertCount(1, $events); $event = array_shift($events); // Checking that the event contains the expected values. $this->assertInstanceOf('\mod_quiz\event\course_module_viewed', $event); $this->assertEquals($this->context, $event->get_context()); $moodlequiz = new \moodle_url('/mod/quiz/view.php', ['id' => $this->cm->id]); $this->assertEquals($moodlequiz, $event->get_url()); $this->assertEventContextNotUsed($event); $this->assertNotEmpty($event->get_name()); // Test user with no capabilities. // We need a explicit prohibit since this capability is only defined in authenticated user and guest roles. assign_capability('mod/quiz:view', CAP_PROHIBIT, $this->studentrole->id, $this->context->id); // Empty all the caches that may be affected by this change. accesslib_clear_all_caches_for_unit_testing(); \course_modinfo::clear_instance_cache(); try { mod_quiz_external::view_quiz($this->quiz->id); $this->fail('Exception expected due to missing capability.'); } catch (moodle_exception $e) { $this->assertEquals('requireloginerror', $e->errorcode); } } /** * Test get_user_attempts * * @todo Remove in Moodle 6.0 as part of MDL-80956 final deprecations. */ public function test_get_user_attempts(): void { // Create a quiz with one attempt finished. [$quiz, $context, $quizobj, $attempt, $attemptobj] = $this->create_quiz_with_questions(true, true); $this->setUser($this->student); $result = mod_quiz_external::get_user_attempts($quiz->id); $this->assertDebuggingCalled(); $result = external_api::clean_returnvalue(mod_quiz_external::get_user_attempts_returns(), $result); $this->assertCount(1, $result['attempts']); $this->assertEquals($attempt->id, $result['attempts'][0]['id']); $this->assertEquals($quiz->id, $result['attempts'][0]['quiz']); $this->assertEquals($this->student->id, $result['attempts'][0]['userid']); $this->assertEquals(1, $result['attempts'][0]['attempt']); $this->assertArrayHasKey('sumgrades', $result['attempts'][0]); $this->assertEquals(1.0, $result['attempts'][0]['sumgrades']); $this->assertEquals(quiz_attempt::FINISHED, $result['attempts'][0]['state']); // Test filters. Only finished. $this->resetDebugging(); $result = mod_quiz_external::get_user_attempts($quiz->id, 0, 'finished', false); $result = external_api::clean_returnvalue(mod_quiz_external::get_user_attempts_returns(), $result); $this->assertDebuggingCalled(); $this->assertCount(1, $result['attempts']); $this->assertEquals($attempt->id, $result['attempts'][0]['id']); // Test filters. All attempts. $this->resetDebugging(); $result = mod_quiz_external::get_user_attempts($quiz->id, 0, 'all', false); $result = external_api::clean_returnvalue(mod_quiz_external::get_user_attempts_returns(), $result); $this->assertDebuggingCalled(); $this->assertCount(1, $result['attempts']); $this->assertEquals($attempt->id, $result['attempts'][0]['id']); // Test filters. Unfinished. $this->resetDebugging(); $result = mod_quiz_external::get_user_attempts($quiz->id, 0, 'unfinished', false); $result = external_api::clean_returnvalue(mod_quiz_external::get_user_attempts_returns(), $result); $this->assertDebuggingCalled(); $this->assertCount(0, $result['attempts']); // Start a new attempt, but not finish it. $timenow = time(); $attempt = quiz_create_attempt($quizobj, 2, false, $timenow, false, $this->student->id); $quba = \question_engine::make_questions_usage_by_activity('mod_quiz', $quizobj->get_context()); $quba->set_preferred_behaviour($quizobj->get_quiz()->preferredbehaviour); quiz_start_new_attempt($quizobj, $quba, $attempt, 1, $timenow); quiz_attempt_save_started($quizobj, $quba, $attempt); // Test filters. All attempts. $this->resetDebugging(); $result = mod_quiz_external::get_user_attempts($quiz->id, 0, 'all', false); $result = external_api::clean_returnvalue(mod_quiz_external::get_user_attempts_returns(), $result); $this->assertDebuggingCalled(); $this->assertCount(2, $result['attempts']); // Test filters. Unfinished. $this->resetDebugging(); $result = mod_quiz_external::get_user_attempts($quiz->id, 0, 'unfinished', false); $result = external_api::clean_returnvalue(mod_quiz_external::get_user_attempts_returns(), $result); $this->assertDebuggingCalled(); $this->assertCount(1, $result['attempts']); // Test manager can see user attempts. $this->setUser($this->teacher); $this->resetDebugging(); $result = mod_quiz_external::get_user_attempts($quiz->id, $this->student->id); $result = external_api::clean_returnvalue(mod_quiz_external::get_user_attempts_returns(), $result); $this->assertDebuggingCalled(); $this->assertCount(1, $result['attempts']); $this->assertEquals($this->student->id, $result['attempts'][0]['userid']); $this->resetDebugging(); $result = mod_quiz_external::get_user_attempts($quiz->id, $this->student->id, 'all'); $result = external_api::clean_returnvalue(mod_quiz_external::get_user_attempts_returns(), $result); $this->assertDebuggingCalled(); $this->assertCount(2, $result['attempts']); $this->assertEquals($this->student->id, $result['attempts'][0]['userid']); // Invalid parameters. try { $this->resetDebugging(); mod_quiz_external::get_user_attempts($quiz->id, $this->student->id, 'INVALID_PARAMETER'); $this->fail('Exception expected due to missing capability.'); } catch (\invalid_parameter_exception $e) { $this->assertDebuggingCalled(); $this->assertEquals('invalidparameter', $e->errorcode); } } /** * Test get_user_attempts with extra grades * * @todo Remove in Moodle 6.0 as part of MDL-80956 final deprecations. */ public function test_get_user_attempts_with_extra_grades(): void { global $DB; // Create a quiz with one attempt finished. [$quiz, , , $attempt, $attemptobj] = $this->create_quiz_with_questions(true, true); // Add some extra grade items. $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz'); $listeninggrade = $quizgenerator->create_grade_item(['quizid' => $attemptobj->get_quizid(), 'name' => 'Listening']); $readinggrade = $quizgenerator->create_grade_item(['quizid' => $attemptobj->get_quizid(), 'name' => 'Reading']); $structure = $attemptobj->get_quizobj()->get_structure(); $structure->update_slot_grade_item($structure->get_slot_by_number(1), $listeninggrade->id); $structure->update_slot_grade_item($structure->get_slot_by_number(2), $readinggrade->id); $this->setUser($this->student); $this->resetDebugging(); $result = mod_quiz_external::get_user_attempts($quiz->id); $result = external_api::clean_returnvalue(mod_quiz_external::get_user_attempts_returns(), $result); $this->assertDebuggingCalled(); $this->assertCount(1, $result['attempts']); $this->assertEquals($attempt->id, $result['attempts'][0]['id']); // Verify additional grades. $this->assertEquals(['name' => 'Listening', 'grade' => 1, 'maxgrade' => 1], $result['attempts'][0]['gradeitemmarks'][0]); $this->assertEquals(['name' => 'Reading', 'grade' => 0, 'maxgrade' => 1], $result['attempts'][0]['gradeitemmarks'][1]); // Now change the review options, so marks are not displayed, and check the result. $DB->set_field('quiz', 'reviewmarks', 0, ['id' => $quiz->id]); $this->resetDebugging(); $result = mod_quiz_external::get_user_attempts($quiz->id); $result = external_api::clean_returnvalue(mod_quiz_external::get_user_attempts_returns(), $result); $this->assertDebuggingCalled(); $this->assertCount(1, $result['attempts']); $this->assertEquals($attempt->id, $result['attempts'][0]['id']); $this->assertArrayNotHasKey('gradeitemmarks', $result['attempts'][0]); } /** * Test get_user_attempts with marks hidden * * @todo Remove in Moodle 6.0 as part of MDL-80956 final deprecations. */ public function test_get_user_attempts_with_marks_hidden(): void { // Create quiz with one attempt finished and hide the mark. list($quiz, $context, $quizobj, $attempt, $attemptobj) = $this->create_quiz_with_questions( true, true, 'deferredfeedback', false, ['marksduring' => 0, 'marksimmediately' => 0, 'marksopen' => 0, 'marksclosed' => 0]); // Student cannot see the grades. $this->setUser($this->student); $this->resetDebugging(); $result = mod_quiz_external::get_user_attempts($quiz->id); $result = external_api::clean_returnvalue(mod_quiz_external::get_user_attempts_returns(), $result); $this->assertDebuggingCalled(); $this->assertCount(1, $result['attempts']); $this->assertEquals($attempt->id, $result['attempts'][0]['id']); $this->assertEquals($quiz->id, $result['attempts'][0]['quiz']); $this->assertEquals($this->student->id, $result['attempts'][0]['userid']); $this->assertEquals(1, $result['attempts'][0]['attempt']); $this->assertArrayHasKey('sumgrades', $result['attempts'][0]); $this->assertEquals(null, $result['attempts'][0]['sumgrades']); // Test manager can see user grades. $this->setUser($this->teacher); $this->resetDebugging(); $result = mod_quiz_external::get_user_attempts($quiz->id, $this->student->id); $result = external_api::clean_returnvalue(mod_quiz_external::get_user_attempts_returns(), $result); $this->assertDebuggingCalled(); $this->assertCount(1, $result['attempts']); $this->assertEquals($attempt->id, $result['attempts'][0]['id']); $this->assertEquals($quiz->id, $result['attempts'][0]['quiz']); $this->assertEquals($this->student->id, $result['attempts'][0]['userid']); $this->assertEquals(1, $result['attempts'][0]['attempt']); $this->assertArrayHasKey('sumgrades', $result['attempts'][0]); $this->assertEquals(1.0, $result['attempts'][0]['sumgrades']); } /** * Test get_user_attempts when the attempt is in 'submitted' state. * * @todo Remove in Moodle 6.0 as part of MDL-80956 final deprecations. * @covers \mod_quiz_external::get_user_attempts */ public function test_get_user_attempts_submitted(): void { // Create a quiz with one attempt. [$quiz, , , $attempt, $attemptobj] = $this->create_quiz_with_questions(true); // Submit the attempt but do not finish it. // Process some responses from the student. $tosubmit = [1 => ['answer' => '3.14']]; $attemptobj->process_submitted_actions(time(), false, $tosubmit); $attemptobj->process_submit(time(), false); $this->setUser($this->student); $result = mod_quiz_external::get_user_attempts($quiz->id); $this->assertDebuggingCalled(); $result = external_api::clean_returnvalue(mod_quiz_external::get_user_attempts_returns(), $result); $this->assertCount(1, $result['attempts']); $this->assertEquals($attempt->id, $result['attempts'][0]['id']); $this->assertEquals($quiz->id, $result['attempts'][0]['quiz']); $this->assertEquals($this->student->id, $result['attempts'][0]['userid']); $this->assertEquals(1, $result['attempts'][0]['attempt']); $this->assertArrayHasKey('sumgrades', $result['attempts'][0]); $this->assertNull($result['attempts'][0]['sumgrades']); // No grades. $this->assertEquals(quiz_attempt::FINISHED, $result['attempts'][0]['state']); // State is returned as finished. } /** * Test get_user_attempts when the attempt is in 'notstarted' state. The attempt should not be returned. * * @todo Remove in Moodle 6.0 as part of MDL-80956 final deprecations. * @covers \mod_quiz_external::get_user_attempts */ public function test_get_user_attempts_notstarted(): void { // Create a quiz. [$quiz, , $quizobj, , ] = $this->create_quiz_with_questions(); // Create an attempt but do not start it. // Now, do one attempt. $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->student->id); quiz_start_new_attempt($quizobj, $quba, $attempt, 1, $timenow); quiz_attempt_save_not_started($quba, $attempt); $this->setUser($this->student); $result = mod_quiz_external::get_user_attempts($quiz->id, $this->student->id, 'all'); $this->assertDebuggingCalled(); $result = external_api::clean_returnvalue(mod_quiz_external::get_user_attempts_returns(), $result); $this->assertCount(0, $result['attempts']); } /** * Test get_quiz_user_attempts * * @covers \mod_quiz_external::get_user_quiz_attempts */ public function test_get_user_quiz_attempts(): void { // Create a quiz with one attempt finished. [$quiz, , $quizobj, $attempt, ] = $this->create_quiz_with_questions(true, true); $this->setUser($this->student); $result = mod_quiz_external::get_user_quiz_attempts($quiz->id); $result = external_api::clean_returnvalue(mod_quiz_external::get_user_quiz_attempts_returns(), $result); $this->assertCount(1, $result['attempts']); $this->assertEquals($attempt->id, $result['attempts'][0]['id']); $this->assertEquals($quiz->id, $result['attempts'][0]['quiz']); $this->assertEquals($this->student->id, $result['attempts'][0]['userid']); $this->assertEquals(1, $result['attempts'][0]['attempt']); $this->assertArrayHasKey('sumgrades', $result['attempts'][0]); $this->assertEquals(1.0, $result['attempts'][0]['sumgrades']); $this->assertEquals(quiz_attempt::FINISHED, $result['attempts'][0]['state']); // Test filters. Only finished. $result = mod_quiz_external::get_user_quiz_attempts($quiz->id, 0, 'finished', false); $result = external_api::clean_returnvalue(mod_quiz_external::get_user_quiz_attempts_returns(), $result); $this->assertCount(1, $result['attempts']); $this->assertEquals($attempt->id, $result['attempts'][0]['id']); // Test filters. All attempts. $result = mod_quiz_external::get_user_quiz_attempts($quiz->id, 0, 'all', false); $result = external_api::clean_returnvalue(mod_quiz_external::get_user_quiz_attempts_returns(), $result); $this->assertCount(1, $result['attempts']); $this->assertEquals($attempt->id, $result['attempts'][0]['id']); // Test filters. Unfinished. $result = mod_quiz_external::get_user_quiz_attempts($quiz->id, 0, 'unfinished', false); $result = external_api::clean_returnvalue(mod_quiz_external::get_user_quiz_attempts_returns(), $result); $this->assertCount(0, $result['attempts']); // Start a new attempt, but not finish it. $timenow = time(); $attempt = quiz_create_attempt($quizobj, 2, false, $timenow, false, $this->student->id); $quba = \question_engine::make_questions_usage_by_activity('mod_quiz', $quizobj->get_context()); $quba->set_preferred_behaviour($quizobj->get_quiz()->preferredbehaviour); quiz_start_new_attempt($quizobj, $quba, $attempt, 1, $timenow); quiz_attempt_save_started($quizobj, $quba, $attempt); // Test filters. All attempts. $result = mod_quiz_external::get_user_quiz_attempts($quiz->id, 0, 'all', false); $result = external_api::clean_returnvalue(mod_quiz_external::get_user_quiz_attempts_returns(), $result); $this->assertCount(2, $result['attempts']); // Test filters. Unfinished. $result = mod_quiz_external::get_user_quiz_attempts($quiz->id, 0, 'unfinished', false); $result = external_api::clean_returnvalue(mod_quiz_external::get_user_quiz_attempts_returns(), $result); $this->assertCount(1, $result['attempts']); // Test manager can see user attempts. $this->setUser($this->teacher); $result = mod_quiz_external::get_user_quiz_attempts($quiz->id, $this->student->id); $result = external_api::clean_returnvalue(mod_quiz_external::get_user_quiz_attempts_returns(), $result); $this->assertCount(1, $result['attempts']); $this->assertEquals($this->student->id, $result['attempts'][0]['userid']); $result = mod_quiz_external::get_user_quiz_attempts($quiz->id, $this->student->id, 'all'); $result = external_api::clean_returnvalue(mod_quiz_external::get_user_quiz_attempts_returns(), $result); $this->assertCount(2, $result['attempts']); $this->assertEquals($this->student->id, $result['attempts'][0]['userid']); // Invalid parameters. try { mod_quiz_external::get_user_quiz_attempts($quiz->id, $this->student->id, 'INVALID_PARAMETER'); $this->fail('Exception expected due to missing capability.'); } catch (\invalid_parameter_exception $e) { $this->assertEquals('invalidparameter', $e->errorcode); } } /** * Test get_user_quiz_attempts with extra grades */ public function test_get_user_quiz_attempts_with_extra_grades(): void { global $DB; // Create a quiz with one attempt finished. [$quiz, , , $attempt, $attemptobj] = $this->create_quiz_with_questions(true, true); // Add some extra grade items. $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz'); $listeninggrade = $quizgenerator->create_grade_item(['quizid' => $attemptobj->get_quizid(), 'name' => 'Listening']); $readinggrade = $quizgenerator->create_grade_item(['quizid' => $attemptobj->get_quizid(), 'name' => 'Reading']); $structure = $attemptobj->get_quizobj()->get_structure(); $structure->update_slot_grade_item($structure->get_slot_by_number(1), $listeninggrade->id); $structure->update_slot_grade_item($structure->get_slot_by_number(2), $readinggrade->id); $this->setUser($this->student); $result = mod_quiz_external::get_user_quiz_attempts($quiz->id); $result = external_api::clean_returnvalue(mod_quiz_external::get_user_quiz_attempts_returns(), $result); $this->assertCount(1, $result['attempts']); $this->assertEquals($attempt->id, $result['attempts'][0]['id']); // Verify additional grades. $this->assertEquals(['name' => 'Listening', 'grade' => 1, 'maxgrade' => 1], $result['attempts'][0]['gradeitemmarks'][0]); $this->assertEquals(['name' => 'Reading', 'grade' => 0, 'maxgrade' => 1], $result['attempts'][0]['gradeitemmarks'][1]); // Now change the review options, so marks are not displayed, and check the result. $DB->set_field('quiz', 'reviewmarks', 0, ['id' => $quiz->id]); $result = mod_quiz_external::get_user_quiz_attempts($quiz->id); $result = external_api::clean_returnvalue(mod_quiz_external::get_user_quiz_attempts_returns(), $result); $this->assertCount(1, $result['attempts']); $this->assertEquals($attempt->id, $result['attempts'][0]['id']); $this->assertArrayNotHasKey('gradeitemmarks', $result['attempts'][0]); } /** * Test get_user_quiz_attempts with marks hidden * * @covers \mod_quiz_external::get_user_quiz_attempts */ public function test_get_user_quiz_attempts_with_marks_hidden(): void { // Create quiz with one attempt finished and hide the mark. [$quiz, , , $attempt, ] = $this->create_quiz_with_questions( true, true, 'deferredfeedback', false, ['marksduring' => 0, 'marksimmediately' => 0, 'marksopen' => 0, 'marksclosed' => 0]); // Student cannot see the grades. $this->setUser($this->student); $result = mod_quiz_external::get_user_quiz_attempts($quiz->id); $result = external_api::clean_returnvalue(mod_quiz_external::get_user_quiz_attempts_returns(), $result); $this->assertCount(1, $result['attempts']); $this->assertEquals($attempt->id, $result['attempts'][0]['id']); $this->assertEquals($quiz->id, $result['attempts'][0]['quiz']); $this->assertEquals($this->student->id, $result['attempts'][0]['userid']); $this->assertEquals(1, $result['attempts'][0]['attempt']); $this->assertArrayHasKey('sumgrades', $result['attempts'][0]); $this->assertEquals(null, $result['attempts'][0]['sumgrades']); // Test manager can see user grades. $this->setUser($this->teacher); $result = mod_quiz_external::get_user_quiz_attempts($quiz->id, $this->student->id); $result = external_api::clean_returnvalue(mod_quiz_external::get_user_quiz_attempts_returns(), $result); $this->assertCount(1, $result['attempts']); $this->assertEquals($attempt->id, $result['attempts'][0]['id']); $this->assertEquals($quiz->id, $result['attempts'][0]['quiz']); $this->assertEquals($this->student->id, $result['attempts'][0]['userid']); $this->assertEquals(1, $result['attempts'][0]['attempt']); $this->assertArrayHasKey('sumgrades', $result['attempts'][0]); $this->assertEquals(1.0, $result['attempts'][0]['sumgrades']); } /** * Test get_user_quiz_attempts when the attempt is in 'submitted' state. * * @covers \mod_quiz_external::get_user_quiz_attempts */ public function test_get_user_quiz_attempts_submitted(): void { // Create a quiz with one attempt. [$quiz, , , $attempt, $attemptobj] = $this->create_quiz_with_questions(true); // Submit the attempt but do not finish it. // Process some responses from the student. $tosubmit = [1 => ['answer' => '3.14']]; $attemptobj->process_submitted_actions(time(), false, $tosubmit); $attemptobj->process_submit(time(), false); $this->setUser($this->student); $result = mod_quiz_external::get_user_quiz_attempts($quiz->id); $result = external_api::clean_returnvalue(mod_quiz_external::get_user_quiz_attempts_returns(), $result); $this->assertCount(1, $result['attempts']); $this->assertEquals($attempt->id, $result['attempts'][0]['id']); $this->assertEquals($quiz->id, $result['attempts'][0]['quiz']); $this->assertEquals($this->student->id, $result['attempts'][0]['userid']); $this->assertEquals(1, $result['attempts'][0]['attempt']); $this->assertArrayHasKey('sumgrades', $result['attempts'][0]); $this->assertNull($result['attempts'][0]['sumgrades']); // No grades. $this->assertEquals(quiz_attempt::SUBMITTED, $result['attempts'][0]['state']); } /** * Test get_user_quiz_attempts when the attempt is in 'notstarted' state. * * @covers \mod_quiz_external::get_user_quiz_attempts */ public function test_get_user_quiz_attempts_notstarted(): void { // Create a quiz. [$quiz, , $quizobj, , ] = $this->create_quiz_with_questions(); // Create an attempt but do not start it. // Now, do one attempt. $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->student->id); quiz_start_new_attempt($quizobj, $quba, $attempt, 1, $timenow); quiz_attempt_save_not_started($quba, $attempt); $this->setUser($this->student); $result = mod_quiz_external::get_user_quiz_attempts($quiz->id, $this->student->id, 'all'); $result = external_api::clean_returnvalue(mod_quiz_external::get_user_quiz_attempts_returns(), $result); $this->assertCount(1, $result['attempts']); $this->assertEquals($attempt->id, $result['attempts'][0]['id']); $this->assertEquals($quiz->id, $result['attempts'][0]['quiz']); $this->assertEquals($this->student->id, $result['attempts'][0]['userid']); $this->assertEquals(1, $result['attempts'][0]['attempt']); $this->assertArrayHasKey('sumgrades', $result['attempts'][0]); $this->assertNull($result['attempts'][0]['sumgrades']); $this->assertEquals(quiz_attempt::NOT_STARTED, $result['attempts'][0]['state']); } /** * Test get_user_best_grade */ public function test_get_user_best_grade(): void { $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz'); $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); $questioncat = $questiongenerator->create_question_category(); // Create a new quiz. $quizapi1 = $quizgenerator->create_instance([ 'name' => 'Test Quiz API 1', 'course' => $this->course->id, 'sumgrades' => 1 ]); $quizapi2 = $quizgenerator->create_instance([ 'name' => 'Test Quiz API 2', 'course' => $this->course->id, 'sumgrades' => 1, 'marksduring' => 0, 'marksimmediately' => 0, 'marksopen' => 0, 'marksclosed' => 0 ]); // Create a question. $question = $questiongenerator->create_question('numerical', null, ['category' => $questioncat->id]); // Add question to the quizzes. quiz_add_quiz_question($question->id, $quizapi1); quiz_add_quiz_question($question->id, $quizapi2); // Create quiz object. $quizapiobj1 = quiz_settings::create($quizapi1->id, $this->student->id); $quizapiobj2 = quiz_settings::create($quizapi2->id, $this->student->id); // Set grade to pass. $item = \grade_item::fetch([ 'courseid' => $this->course->id, 'itemtype' => 'mod', 'itemmodule' => 'quiz', 'iteminstance' => $quizapi1->id, 'outcomeid' => null ]); $item->gradepass = 80; $item->update(); $item = \grade_item::fetch([ 'courseid' => $this->course->id, 'itemtype' => 'mod', 'itemmodule' => 'quiz', 'iteminstance' => $quizapi2->id, 'outcomeid' => null ]); $item->gradepass = 80; $item->update(); // Start the passing attempt. $quba1 = \question_engine::make_questions_usage_by_activity('mod_quiz', $quizapiobj1->get_context()); $quba1->set_preferred_behaviour($quizapiobj1->get_quiz()->preferredbehaviour); $quba2 = \question_engine::make_questions_usage_by_activity('mod_quiz', $quizapiobj2->get_context()); $quba2->set_preferred_behaviour($quizapiobj2->get_quiz()->preferredbehaviour); // Start the testing for quizapi1 that allow the student to view the grade. $this->setUser($this->student); $result = mod_quiz_external::get_user_best_grade($quizapi1->id); $result = external_api::clean_returnvalue(mod_quiz_external::get_user_best_grade_returns(), $result); // No grades yet. $this->assertFalse($result['hasgrade']); $this->assertTrue(!isset($result['grade'])); // Start the attempt. $timenow = time(); $attempt = quiz_create_attempt($quizapiobj1, 1, false, $timenow, false, $this->student->id); quiz_start_new_attempt($quizapiobj1, $quba1, $attempt, 1, $timenow); quiz_attempt_save_started($quizapiobj1, $quba1, $attempt); // Process some responses from the student. $attemptobj = quiz_attempt::create($attempt->id); $attemptobj->process_submitted_actions($timenow, false, [1 => ['answer' => '3.14']]); // Finish the attempt. $attemptobj->process_submit($timenow, false); $attemptobj->process_grade_submission($timenow); $result = mod_quiz_external::get_user_best_grade($quizapi1->id); $result = external_api::clean_returnvalue(mod_quiz_external::get_user_best_grade_returns(), $result); // Now I have grades. $this->assertTrue($result['hasgrade']); $this->assertEquals(100.0, $result['grade']); $this->assertEquals(80, $result['gradetopass']); // We should not see other users grades. $anotherstudent = self::getDataGenerator()->create_user(); $this->getDataGenerator()->enrol_user($anotherstudent->id, $this->course->id, $this->studentrole->id, 'manual'); try { mod_quiz_external::get_user_best_grade($quizapi1->id, $anotherstudent->id); $this->fail('Exception expected due to missing capability.'); } catch (\required_capability_exception $e) { $this->assertEquals('nopermissions', $e->errorcode); } // Teacher must be able to see student grades. $this->setUser($this->teacher); $result = mod_quiz_external::get_user_best_grade($quizapi1->id, $this->student->id); $result = external_api::clean_returnvalue(mod_quiz_external::get_user_best_grade_returns(), $result); $this->assertTrue($result['hasgrade']); $this->assertEquals(100.0, $result['grade']); $this->assertEquals(80, $result['gradetopass']); // Invalid user. try { mod_quiz_external::get_user_best_grade($this->quiz->id, -1); $this->fail('Exception expected due to missing capability.'); } catch (\dml_missing_record_exception $e) { $this->assertEquals('invaliduser', $e->errorcode); } // End the testing for quizapi1 that allow the student to view the grade. // Start the testing for quizapi2 that do not allow the student to view the grade. $this->setUser($this->student); $result = mod_quiz_external::get_user_best_grade($quizapi2->id); $result = external_api::clean_returnvalue(mod_quiz_external::get_user_best_grade_returns(), $result); // No grades yet. $this->assertFalse($result['hasgrade']); $this->assertTrue(!isset($result['grade'])); // Start the attempt. $timenow = time(); $attempt = quiz_create_attempt($quizapiobj2, 1, false, $timenow, false, $this->student->id); quiz_start_new_attempt($quizapiobj2, $quba2, $attempt, 1, $timenow); quiz_attempt_save_started($quizapiobj2, $quba2, $attempt); // Process some responses from the student. $attemptobj = quiz_attempt::create($attempt->id); $attemptobj->process_submitted_actions($timenow, false, [1 => ['answer' => '3.14']]); // Finish the attempt. $attemptobj->process_submit($timenow, false); $attemptobj->process_grade_submission($timenow); $result = mod_quiz_external::get_user_best_grade($quizapi2->id); $result = external_api::clean_returnvalue(mod_quiz_external::get_user_best_grade_returns(), $result); // Now I have grades but I will not be allowed to see it. $this->assertFalse($result['hasgrade']); $this->assertTrue(!isset($result['grade'])); // Teacher must be able to see student grades. $this->setUser($this->teacher); $result = mod_quiz_external::get_user_best_grade($quizapi2->id, $this->student->id); $result = external_api::clean_returnvalue(mod_quiz_external::get_user_best_grade_returns(), $result); $this->assertTrue($result['hasgrade']); $this->assertEquals(100.0, $result['grade']); // End the testing for quizapi2 that do not allow the student to view the grade. } /** * Test get_combined_review_options. * This is a basic test, this is already tested in display_options_testcase. */ public function test_get_combined_review_options(): void { global $DB; // Create a new quiz with attempts. $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz'); $data = ['course' => $this->course->id, 'sumgrades' => 1]; $quiz = $quizgenerator->create_instance($data); // Create a couple of questions. $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); $cat = $questiongenerator->create_question_category(); $question = $questiongenerator->create_question('numerical', null, ['category' => $cat->id]); quiz_add_quiz_question($question->id, $quiz); $quizobj = quiz_settings::create($quiz->id, $this->student->id); // Set grade to pass. $item = \grade_item::fetch(['courseid' => $this->course->id, 'itemtype' => 'mod', 'itemmodule' => 'quiz', 'iteminstance' => $quiz->id, 'outcomeid' => null]); $item->gradepass = 80; $item->update(); // Start the passing attempt. $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->student->id); quiz_start_new_attempt($quizobj, $quba, $attempt, 1, $timenow); quiz_attempt_save_started($quizobj, $quba, $attempt); $this->setUser($this->student); $result = mod_quiz_external::get_combined_review_options($quiz->id); $result = external_api::clean_returnvalue(mod_quiz_external::get_combined_review_options_returns(), $result); // Expected values. $expected = [ "someoptions" => [ ["name" => "feedback", "value" => 1], ["name" => "generalfeedback", "value" => 1], ["name" => "rightanswer", "value" => 1], ["name" => "overallfeedback", "value" => 0], ["name" => "marks", "value" => 2], ], "alloptions" => [ ["name" => "feedback", "value" => 1], ["name" => "generalfeedback", "value" => 1], ["name" => "rightanswer", "value" => 1], ["name" => "overallfeedback", "value" => 0], ["name" => "marks", "value" => 2], ], "warnings" => [], ]; $this->assertEquals($expected, $result); // Now, finish the attempt. $attemptobj = quiz_attempt::create($attempt->id); $attemptobj->process_submit($timenow, false); $attemptobj->process_grade_submission($timenow); $expected = [ "someoptions" => [ ["name" => "feedback", "value" => 1], ["name" => "generalfeedback", "value" => 1], ["name" => "rightanswer", "value" => 1], ["name" => "overallfeedback", "value" => 1], ["name" => "marks", "value" => 2], ], "alloptions" => [ ["name" => "feedback", "value" => 1], ["name" => "generalfeedback", "value" => 1], ["name" => "rightanswer", "value" => 1], ["name" => "overallfeedback", "value" => 1], ["name" => "marks", "value" => 2], ], "warnings" => [], ]; // We should see now the overall feedback. $result = mod_quiz_external::get_combined_review_options($quiz->id); $result = external_api::clean_returnvalue(mod_quiz_external::get_combined_review_options_returns(), $result); $this->assertEquals($expected, $result); // Start a new attempt, but not finish it. $timenow = time(); $attempt = quiz_create_attempt($quizobj, 2, false, $timenow, false, $this->student->id); $quba = \question_engine::make_questions_usage_by_activity('mod_quiz', $quizobj->get_context()); $quba->set_preferred_behaviour($quizobj->get_quiz()->preferredbehaviour); quiz_start_new_attempt($quizobj, $quba, $attempt, 1, $timenow); quiz_attempt_save_started($quizobj, $quba, $attempt); $expected = [ "someoptions" => [ ["name" => "feedback", "value" => 1], ["name" => "generalfeedback", "value" => 1], ["name" => "rightanswer", "value" => 1], ["name" => "overallfeedback", "value" => 1], ["name" => "marks", "value" => 2], ], "alloptions" => [ ["name" => "feedback", "value" => 1], ["name" => "generalfeedback", "value" => 1], ["name" => "rightanswer", "value" => 1], ["name" => "overallfeedback", "value" => 0], ["name" => "marks", "value" => 2], ], "warnings" => [], ]; $result = mod_quiz_external::get_combined_review_options($quiz->id); $result = external_api::clean_returnvalue(mod_quiz_external::get_combined_review_options_returns(), $result); $this->assertEquals($expected, $result); // Teacher, for see student options. $this->setUser($this->teacher); $result = mod_quiz_external::get_combined_review_options($quiz->id, $this->student->id); $result = external_api::clean_returnvalue(mod_quiz_external::get_combined_review_options_returns(), $result); $this->assertEquals($expected, $result); // Invalid user. try { mod_quiz_external::get_combined_review_options($quiz->id, -1); $this->fail('Exception expected due to missing capability.'); } catch (\dml_missing_record_exception $e) { $this->assertEquals('invaliduser', $e->errorcode); } } /** * Test get_combined_review_options when the user has an override. * * @covers ::get_combined_review_options * @covers ::get_combined_review_options_parameters * @covers ::get_combined_review_options_returns */ public function test_get_combined_review_options_with_overrides(): void { global $DB; // Create a closed quiz with review marks only when quiz is closed. list($quiz, $context, $quizobj) = $this->create_quiz_with_questions(true, true, 'deferredfeedback', false, [ 'timeclose' => time() - HOURSECS, 'marksduring' => 0, 'maxmarksduring' => 0, 'marksimmediately' => 0, 'maxmarksimmediately' => 0, 'marksopen' => 0, 'maxmarksopen' => 0, 'marksclosed' => 1, 'maxmarksclosed' => 1, ]); // Check that the student can see the marks because the quiz is closed. $this->setUser($this->student); $expected = [ "someoptions" => [ ["name" => "feedback", "value" => 1], ["name" => "generalfeedback", "value" => 1], ["name" => "rightanswer", "value" => 1], ["name" => "overallfeedback", "value" => 1], ["name" => "marks", "value" => 2], ], "alloptions" => [ ["name" => "feedback", "value" => 1], ["name" => "generalfeedback", "value" => 1], ["name" => "rightanswer", "value" => 1], ["name" => "overallfeedback", "value" => 1], ["name" => "marks", "value" => 2], ], "warnings" => [], ]; $result = mod_quiz_external::get_combined_review_options($quiz->id); $result = external_api::clean_returnvalue(mod_quiz_external::get_combined_review_options_returns(), $result); $this->assertEquals($expected, $result); // Add an override for the student to increase the close time. $DB->insert_record('quiz_overrides', [ 'quiz' => $quiz->id, 'userid' => $this->student->id, 'timeclose' => time() + HOURSECS, ]); // Check that now the marks option has changed. $expected = [ "someoptions" => [ ["name" => "feedback", "value" => 1], ["name" => "generalfeedback", "value" => 1], ["name" => "rightanswer", "value" => 1], ["name" => "overallfeedback", "value" => 1], ["name" => "marks", "value" => 0], ], "alloptions" => [ ["name" => "feedback", "value" => 1], ["name" => "generalfeedback", "value" => 1], ["name" => "rightanswer", "value" => 1], ["name" => "overallfeedback", "value" => 1], ["name" => "marks", "value" => 0], ], "warnings" => [], ]; $result = mod_quiz_external::get_combined_review_options($quiz->id); $result = external_api::clean_returnvalue(mod_quiz_external::get_combined_review_options_returns(), $result); $this->assertEquals($expected, $result); } /** * Test start_attempt */ public function test_start_attempt(): void { global $DB; // Create a new quiz with questions. list($quiz, $context, $quizobj) = $this->create_quiz_with_questions(); $this->setUser($this->student); // Try to open attempt in closed quiz. $quiz->timeopen = time() - WEEKSECS; $quiz->timeclose = time() - DAYSECS; $DB->update_record('quiz', $quiz); $result = mod_quiz_external::start_attempt($quiz->id); $result = external_api::clean_returnvalue(mod_quiz_external::start_attempt_returns(), $result); $this->assertEquals([], $result['attempt']); $this->assertCount(1, $result['warnings']); // Now with a password. $quiz->timeopen = 0; $quiz->timeclose = 0; $quiz->password = 'abc'; $DB->update_record('quiz', $quiz); try { mod_quiz_external::start_attempt($quiz->id, [["name" => "quizpassword", "value" => 'bad']]); $this->fail('Exception expected due to invalid passwod.'); } catch (moodle_exception $e) { $this->assertEquals(get_string('passworderror', 'quizaccess_password'), $e->errorcode); } // Now, try everything correct. $result = mod_quiz_external::start_attempt($quiz->id, [["name" => "quizpassword", "value" => 'abc']]); $result = external_api::clean_returnvalue(mod_quiz_external::start_attempt_returns(), $result); $this->assertEquals(1, $result['attempt']['attempt']); $this->assertEquals($this->student->id, $result['attempt']['userid']); $this->assertEquals($quiz->id, $result['attempt']['quiz']); $this->assertCount(0, $result['warnings']); $attemptid = $result['attempt']['id']; // We are good, try to start a new attempt now. try { mod_quiz_external::start_attempt($quiz->id, [["name" => "quizpassword", "value" => 'abc']]); $this->fail('Exception expected due to attempt not finished.'); } catch (moodle_exception $e) { $this->assertEquals('attemptstillinprogress', $e->errorcode); } // Finish the started attempt. // Process some responses from the student. $timenow = time(); $attemptobj = quiz_attempt::create($attemptid); $tosubmit = [1 => ['answer' => '3.14']]; $attemptobj->process_submitted_actions($timenow, false, $tosubmit); // Finish the attempt. $attemptobj = quiz_attempt::create($attemptid); $this->assertTrue($attemptobj->has_response_to_at_least_one_graded_question()); $attemptobj->process_submit($timenow, false); $attemptobj->process_grade_submission($timenow); // We should be able to start a new attempt. $result = mod_quiz_external::start_attempt($quiz->id, [["name" => "quizpassword", "value" => 'abc']]); $result = external_api::clean_returnvalue(mod_quiz_external::start_attempt_returns(), $result); $this->assertEquals(2, $result['attempt']['attempt']); $this->assertEquals($this->student->id, $result['attempt']['userid']); $this->assertEquals($quiz->id, $result['attempt']['quiz']); $this->assertCount(0, $result['warnings']); // Test user with no capabilities. // We need a explicit prohibit since this capability is only defined in authenticated user and guest roles. assign_capability('mod/quiz:attempt', CAP_PROHIBIT, $this->studentrole->id, $context->id); // Empty all the caches that may be affected by this change. accesslib_clear_all_caches_for_unit_testing(); \course_modinfo::clear_instance_cache(); try { mod_quiz_external::start_attempt($quiz->id); $this->fail('Exception expected due to missing capability.'); } catch (\required_capability_exception $e) { $this->assertEquals('nopermissions', $e->errorcode); } } /** * Test validate_attempt */ public function test_validate_attempt(): void { global $DB; // Create a new quiz with one attempt started. list($quiz, $context, $quizobj, $attempt, $attemptobj) = $this->create_quiz_with_questions(true); $this->setUser($this->student); // Invalid attempt. try { $params = ['attemptid' => -1, 'page' => 0]; testable_mod_quiz_external::validate_attempt($params); $this->fail('Exception expected due to invalid attempt id.'); } catch (\dml_missing_record_exception $e) { $this->assertEquals('invalidrecord', $e->errorcode); } // Test OK case. $params = ['attemptid' => $attempt->id, 'page' => 0]; $result = testable_mod_quiz_external::validate_attempt($params); $this->assertEquals($attempt->id, $result[0]->get_attempt()->id); $this->assertEquals([], $result[1]); // Test with preflight data. $quiz->password = 'abc'; $DB->update_record('quiz', $quiz); try { $params = ['attemptid' => $attempt->id, 'page' => 0, 'preflightdata' => [["name" => "quizpassword", "value" => 'bad']]]; testable_mod_quiz_external::validate_attempt($params); $this->fail('Exception expected due to invalid passwod.'); } catch (moodle_exception $e) { $this->assertEquals(get_string('passworderror', 'quizaccess_password'), $e->errorcode); } // Now, try everything correct. $params['preflightdata'][0]['value'] = 'abc'; $result = testable_mod_quiz_external::validate_attempt($params); $this->assertEquals($attempt->id, $result[0]->get_attempt()->id); $this->assertEquals([], $result[1]); // Page out of range. $DB->update_record('quiz', $quiz); $params['page'] = 4; try { testable_mod_quiz_external::validate_attempt($params); $this->fail('Exception expected due to page out of range.'); } catch (moodle_exception $e) { $this->assertEquals('Invalid page number', $e->errorcode); } $params['page'] = 0; // Try to open attempt in closed quiz. $quiz->timeopen = time() - WEEKSECS; $quiz->timeclose = time() - DAYSECS; $DB->update_record('quiz', $quiz); // This should work, ommit access rules. testable_mod_quiz_external::validate_attempt($params, false); // Get a generic error because prior to checking the dates the attempt is closed. try { testable_mod_quiz_external::validate_attempt($params); $this->fail('Exception expected due to passed dates.'); } catch (moodle_exception $e) { $this->assertEquals('attempterror', $e->errorcode); } // Finish the attempt. $attemptobj = quiz_attempt::create($attempt->id); $attemptobj->process_submit(time(), false); $attemptobj->process_grade_submission(time()); try { testable_mod_quiz_external::validate_attempt($params, false); $this->fail('Exception expected due to attempt finished.'); } catch (moodle_exception $e) { $this->assertEquals('attemptalreadyclosed', $e->errorcode); } // Test user with no capabilities. // We need a explicit prohibit since this capability is only defined in authenticated user and guest roles. assign_capability('mod/quiz:attempt', CAP_PROHIBIT, $this->studentrole->id, $context->id); // Empty all the caches that may be affected by this change. accesslib_clear_all_caches_for_unit_testing(); \course_modinfo::clear_instance_cache(); try { testable_mod_quiz_external::validate_attempt($params); $this->fail('Exception expected due to missing permissions.'); } catch (\required_capability_exception $e) { $this->assertEquals('nopermissions', $e->errorcode); } // Now try with a different user. $this->setUser($this->teacher); $params['page'] = 0; try { testable_mod_quiz_external::validate_attempt($params); $this->fail('Exception expected due to not your attempt.'); } catch (moodle_exception $e) { $this->assertEquals('notyourattempt', $e->errorcode); } } /** * Test get_attempt_data */ public function test_get_attempt_data(): void { global $DB; $timenow = time(); // Create a new quiz with one attempt started. [$quiz, , $quizobj, $attempt] = $this->create_quiz_with_questions(true); /** @var structure $structure */ $structure = $quizobj->get_structure(); $structure->update_slot_display_number($structure->get_slot_id_for_slot(1), '1.a'); // Set correctness mask so questions state can be fetched only after finishing the attempt. $DB->set_field('quiz', 'reviewcorrectness', display_options::IMMEDIATELY_AFTER, ['id' => $quiz->id]); // Having changed some settings, recreate the objects. $attemptobj = quiz_attempt::create($attempt->id); $quizobj = $attemptobj->get_quizobj(); $quizobj->preload_questions(); $quizobj->load_questions(); $questions = $quizobj->get_questions(); $this->setUser($this->student); // We receive one question per page. $result = mod_quiz_external::get_attempt_data($attempt->id, 0); $result = external_api::clean_returnvalue(mod_quiz_external::get_attempt_data_returns(), $result); $this->assertEquals($attempt, (object) $result['attempt']); $this->assertEquals(1, $result['nextpage']); $this->assertCount(0, $result['messages']); $this->assertCount(1, $result['questions']); $this->assertEquals(1, $result['questions'][0]['slot']); $this->assertArrayNotHasKey('number', $result['questions'][0]); $this->assertEquals('1.a', $result['questions'][0]['questionnumber']); $this->assertEquals('numerical', $result['questions'][0]['type']); $this->assertEquals('notyetanswered', $result['questions'][0]['stateclass']); $this->assertArrayNotHasKey('state', $result['questions'][0]); // We don't receive the state yet. $this->assertEquals('notyetanswered', $result['questions'][0]['stateclass']); $this->assertEquals(get_string('notyetanswered', 'question'), $result['questions'][0]['status']); $this->assertFalse($result['questions'][0]['flagged']); $this->assertEquals(0, $result['questions'][0]['page']); $this->assertEmpty($result['questions'][0]['mark']); $this->assertEquals(1, $result['questions'][0]['maxmark']); $this->assertEquals(1, $result['questions'][0]['sequencecheck']); $this->assertEquals(\question_attempt_step::TIMECREATED_ON_FIRST_RENDER, $result['questions'][0]['lastactiontime']); $this->assertEquals(false, $result['questions'][0]['hasautosavedstep']); // Now try the last page. $result = mod_quiz_external::get_attempt_data($attempt->id, 1); $result = external_api::clean_returnvalue(mod_quiz_external::get_attempt_data_returns(), $result); $this->assertEquals($attempt, (object) $result['attempt']); $this->assertEquals(-1, $result['nextpage']); $this->assertCount(0, $result['messages']); $this->assertCount(1, $result['questions']); $this->assertEquals(2, $result['questions'][0]['slot']); $this->assertEquals(2, $result['questions'][0]['questionnumber']); $this->assertEquals(2, $result['questions'][0]['number']); $this->assertEquals('numerical', $result['questions'][0]['type']); $this->assertEquals('notyetanswered', $result['questions'][0]['stateclass']); $this->assertArrayNotHasKey('state', $result['questions'][0]); // We don't receive the state yet. $this->assertEquals(get_string('notyetanswered', 'question'), $result['questions'][0]['status']); $this->assertFalse($result['questions'][0]['flagged']); $this->assertEquals(1, $result['questions'][0]['page']); $this->assertEquals(1, $result['questions'][0]['sequencecheck']); $this->assertEquals(\question_attempt_step::TIMECREATED_ON_FIRST_RENDER, $result['questions'][0]['lastactiontime']); $this->assertEquals(false, $result['questions'][0]['hasautosavedstep']); // Finish previous attempt. $attemptobj->process_submit(time(), false); $attemptobj->process_grade_submission(time()); // Now we should receive the question state. $result = mod_quiz_external::get_attempt_review($attempt->id, 1); $result = external_api::clean_returnvalue(mod_quiz_external::get_attempt_review_returns(), $result); $this->assertEquals('notanswered', $result['questions'][0]['stateclass']); $this->assertEquals('gaveup', $result['questions'][0]['state']); // Change setting and expect two pages. $quiz->questionsperpage = 4; $DB->update_record('quiz', $quiz); quiz_repaginate_questions($quiz->id, $quiz->questionsperpage); // Start with new attempt with the new layout. $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, 2, false, $timenow, false, $this->student->id); quiz_start_new_attempt($quizobj, $quba, $attempt, 1, $timenow); quiz_attempt_save_started($quizobj, $quba, $attempt); // We receive two questions per page. $result = mod_quiz_external::get_attempt_data($attempt->id, 0); $result = external_api::clean_returnvalue(mod_quiz_external::get_attempt_data_returns(), $result); $this->assertCount(2, $result['questions']); $this->assertEquals(-1, $result['nextpage']); // Check questions looks good. $found = 0; foreach ($questions as $question) { foreach ($result['questions'] as $rquestion) { if ($rquestion['slot'] == $question->slot) { $this->assertTrue(strpos($rquestion['html'], "qid=$question->id") !== false); $found++; } } } $this->assertEquals(2, $found); } /** * Test get_attempt_data with blocked questions. * @since 3.2 */ public function test_get_attempt_data_with_blocked_questions(): void { global $DB; // Create a new quiz with one attempt started and using immediatefeedback. list($quiz, $context, $quizobj, $attempt, $attemptobj) = $this->create_quiz_with_questions( true, false, 'immediatefeedback'); $quizobj = $attemptobj->get_quizobj(); // Make second question blocked by the first one. $structure = $quizobj->get_structure(); $slots = $structure->get_slots(); $structure->update_question_dependency(end($slots)->id, true); $quizobj->preload_questions(); $quizobj->load_questions(); $questions = $quizobj->get_questions(); $this->setUser($this->student); // We receive one question per page. $result = mod_quiz_external::get_attempt_data($attempt->id, 0); $result = external_api::clean_returnvalue(mod_quiz_external::get_attempt_data_returns(), $result); $this->assertEquals($attempt, (object) $result['attempt']); $this->assertCount(1, $result['questions']); $this->assertEquals(1, $result['questions'][0]['slot']); $this->assertEquals(1, $result['questions'][0]['number']); $this->assertEquals(false, $result['questions'][0]['blockedbyprevious']); // Now try the last page. $result = mod_quiz_external::get_attempt_data($attempt->id, 1); $result = external_api::clean_returnvalue(mod_quiz_external::get_attempt_data_returns(), $result); $this->assertEquals($attempt, (object) $result['attempt']); $this->assertCount(1, $result['questions']); $this->assertEquals(2, $result['questions'][0]['slot']); $this->assertEquals(2, $result['questions'][0]['number']); $this->assertEquals(true, $result['questions'][0]['blockedbyprevious']); } /** * Test get_attempt_summary */ public function test_get_attempt_summary(): void { $timenow = time(); // Create a new quiz with one attempt started. list($quiz, $context, $quizobj, $attempt, $attemptobj) = $this->create_quiz_with_questions(true); $this->setUser($this->student); $result = mod_quiz_external::get_attempt_summary($attempt->id); $result = external_api::clean_returnvalue(mod_quiz_external::get_attempt_summary_returns(), $result); // Check the state, flagged and mark data is correct. $this->assertEquals('todo', $result['questions'][0]['state']); $this->assertEquals('notyetanswered', $result['questions'][0]['stateclass']); $this->assertEquals('todo', $result['questions'][1]['state']); $this->assertEquals('notyetanswered', $result['questions'][1]['stateclass']); $this->assertEquals(1, $result['questions'][0]['number']); $this->assertEquals(2, $result['questions'][1]['number']); $this->assertFalse($result['questions'][0]['flagged']); $this->assertFalse($result['questions'][1]['flagged']); $this->assertEmpty($result['questions'][0]['mark']); $this->assertEmpty($result['questions'][1]['mark']); $this->assertEquals(1, $result['questions'][0]['sequencecheck']); $this->assertEquals(1, $result['questions'][1]['sequencecheck']); $this->assertEquals(\question_attempt_step::TIMECREATED_ON_FIRST_RENDER, $result['questions'][0]['lastactiontime']); $this->assertEquals(\question_attempt_step::TIMECREATED_ON_FIRST_RENDER, $result['questions'][1]['lastactiontime']); $this->assertEquals(false, $result['questions'][0]['hasautosavedstep']); $this->assertEquals(false, $result['questions'][1]['hasautosavedstep']); // Check question options. $this->assertNotEmpty(5, $result['questions'][0]['settings']); // Check at least some settings returned. $this->assertCount(4, (array) json_decode($result['questions'][0]['settings'])); $this->assertEquals(2, $result['totalunanswered']); // All questions are unanswered. // Submit a response for the first question. $tosubmit = [1 => ['answer' => '3.14']]; $attemptobj->process_submitted_actions(time(), false, $tosubmit); $result = mod_quiz_external::get_attempt_summary($attempt->id); $result = external_api::clean_returnvalue(mod_quiz_external::get_attempt_summary_returns(), $result); // Check it's marked as completed only the first one. $this->assertEquals('complete', $result['questions'][0]['state']); $this->assertEquals('answersaved', $result['questions'][0]['stateclass']); $this->assertEquals('todo', $result['questions'][1]['state']); $this->assertEquals('notyetanswered', $result['questions'][1]['stateclass']); $this->assertEquals(1, $result['questions'][0]['number']); $this->assertEquals(2, $result['questions'][1]['number']); $this->assertFalse($result['questions'][0]['flagged']); $this->assertFalse($result['questions'][1]['flagged']); $this->assertEmpty($result['questions'][0]['mark']); $this->assertEmpty($result['questions'][1]['mark']); $this->assertEquals(2, $result['questions'][0]['sequencecheck']); $this->assertEquals(1, $result['questions'][1]['sequencecheck']); $this->assertGreaterThanOrEqual($timenow, $result['questions'][0]['lastactiontime']); $this->assertGreaterThanOrEqual($timenow, $result['questions'][1]['lastactiontime']); $this->assertEquals(false, $result['questions'][0]['hasautosavedstep']); $this->assertEquals(false, $result['questions'][1]['hasautosavedstep']); $this->assertEquals(1, $result['totalunanswered']); // Only one question is unanswered. } /** * Test save_attempt */ public function test_save_attempt(): void { $timenow = time(); // Create a new quiz with one attempt started. list($quiz, $context, $quizobj, $attempt, $attemptobj, $quba) = $this->create_quiz_with_questions(true); // Response for slot 1. $prefix = $quba->get_field_prefix(1); $data = [ ['name' => 'slots', 'value' => 1], ['name' => $prefix . ':sequencecheck', 'value' => $attemptobj->get_question_attempt(1)->get_sequence_check_count()], ['name' => $prefix . 'answer', 'value' => 1], ]; $this->setUser($this->student); $result = mod_quiz_external::save_attempt($attempt->id, $data); $result = external_api::clean_returnvalue(mod_quiz_external::save_attempt_returns(), $result); $this->assertTrue($result['status']); // Now, get the summary. $result = mod_quiz_external::get_attempt_summary($attempt->id); $result = external_api::clean_returnvalue(mod_quiz_external::get_attempt_summary_returns(), $result); // Check it's marked as completed only the first one. $this->assertEquals('complete', $result['questions'][0]['state']); $this->assertEquals('answersaved', $result['questions'][0]['stateclass']); $this->assertEquals('todo', $result['questions'][1]['state']); $this->assertEquals('notyetanswered', $result['questions'][1]['stateclass']); $this->assertEquals(1, $result['questions'][0]['number']); $this->assertEquals(2, $result['questions'][1]['number']); $this->assertFalse($result['questions'][0]['flagged']); $this->assertFalse($result['questions'][1]['flagged']); $this->assertEmpty($result['questions'][0]['mark']); $this->assertEmpty($result['questions'][1]['mark']); $this->assertEquals(1, $result['questions'][0]['sequencecheck']); $this->assertEquals(1, $result['questions'][1]['sequencecheck']); $this->assertGreaterThanOrEqual($timenow, $result['questions'][0]['lastactiontime']); $this->assertEquals(\question_attempt_step::TIMECREATED_ON_FIRST_RENDER, $result['questions'][1]['lastactiontime']); $this->assertEquals(true, $result['questions'][0]['hasautosavedstep']); $this->assertEquals(false, $result['questions'][1]['hasautosavedstep']); // Now, second slot. $prefix = $quba->get_field_prefix(2); $data = [ ['name' => 'slots', 'value' => 2], ['name' => $prefix . ':sequencecheck', 'value' => $attemptobj->get_question_attempt(1)->get_sequence_check_count()], ['name' => $prefix . 'answer', 'value' => 1], ]; $result = mod_quiz_external::save_attempt($attempt->id, $data); $result = external_api::clean_returnvalue(mod_quiz_external::save_attempt_returns(), $result); $this->assertTrue($result['status']); // Now, get the summary. $result = mod_quiz_external::get_attempt_summary($attempt->id); $result = external_api::clean_returnvalue(mod_quiz_external::get_attempt_summary_returns(), $result); // Check it's marked as completed only the first one. $this->assertEquals('complete', $result['questions'][0]['state']); $this->assertEquals('answersaved', $result['questions'][0]['stateclass']); $this->assertEquals(1, $result['questions'][0]['sequencecheck']); $this->assertEquals('complete', $result['questions'][1]['state']); $this->assertEquals('answersaved', $result['questions'][1]['stateclass']); $this->assertEquals(1, $result['questions'][1]['sequencecheck']); } /** * Test process_attempt */ public function test_process_attempt(): void { global $DB; $timenow = time(); // Create a new quiz with three questions and one attempt started. list($quiz, $context, $quizobj, $attempt, $attemptobj, $quba) = $this->create_quiz_with_questions(true, false, 'deferredfeedback', true); // Response for slot 1. $prefix = $quba->get_field_prefix(1); $data = [ ['name' => 'slots', 'value' => 1], ['name' => $prefix . ':sequencecheck', 'value' => $attemptobj->get_question_attempt(1)->get_sequence_check_count()], ['name' => $prefix . 'answer', 'value' => 1], ]; $this->setUser($this->student); $result = mod_quiz_external::process_attempt($attempt->id, $data); $result = external_api::clean_returnvalue(mod_quiz_external::process_attempt_returns(), $result); $this->assertEquals(quiz_attempt::IN_PROGRESS, $result['state']); $result = mod_quiz_external::get_attempt_data($attempt->id, 2); // Now, get the summary. $result = mod_quiz_external::get_attempt_summary($attempt->id); $result = external_api::clean_returnvalue(mod_quiz_external::get_attempt_summary_returns(), $result); // Check it's marked as completed only the first one. $this->assertEquals('complete', $result['questions'][0]['state']); $this->assertEquals('todo', $result['questions'][1]['state']); $this->assertEquals(1, $result['questions'][0]['number']); $this->assertEquals(2, $result['questions'][1]['number']); $this->assertFalse($result['questions'][0]['flagged']); $this->assertFalse($result['questions'][1]['flagged']); $this->assertEmpty($result['questions'][0]['mark']); $this->assertEmpty($result['questions'][1]['mark']); $this->assertEquals(2, $result['questions'][0]['sequencecheck']); $this->assertEquals(2, $result['questions'][0]['sequencecheck']); $this->assertGreaterThanOrEqual($timenow, $result['questions'][0]['lastactiontime']); $this->assertGreaterThanOrEqual($timenow, $result['questions'][0]['lastactiontime']); $this->assertEquals(false, $result['questions'][0]['hasautosavedstep']); $this->assertEquals(false, $result['questions'][0]['hasautosavedstep']); // Now, second slot. $prefix = $quba->get_field_prefix(2); $data = [ ['name' => 'slots', 'value' => 2], ['name' => $prefix . ':sequencecheck', 'value' => $attemptobj->get_question_attempt(1)->get_sequence_check_count()], ['name' => $prefix . 'answer', 'value' => 1], ['name' => $prefix . ':flagged', 'value' => 1], ]; $result = mod_quiz_external::process_attempt($attempt->id, $data); $result = external_api::clean_returnvalue(mod_quiz_external::process_attempt_returns(), $result); $this->assertEquals(quiz_attempt::IN_PROGRESS, $result['state']); // Now, get the summary. $result = mod_quiz_external::get_attempt_summary($attempt->id); $result = external_api::clean_returnvalue(mod_quiz_external::get_attempt_summary_returns(), $result); // Check it's marked as completed the two first questions. $this->assertEquals('complete', $result['questions'][0]['state']); $this->assertEquals('complete', $result['questions'][1]['state']); $this->assertFalse($result['questions'][0]['flagged']); $this->assertTrue($result['questions'][1]['flagged']); // Add files in the attachment response. $draftitemid = file_get_unused_draft_itemid(); $filerecordinline = [ 'contextid' => \context_user::instance($this->student->id)->id, 'component' => 'user', 'filearea' => 'draft', 'itemid' => $draftitemid, 'filepath' => '/', 'filename' => 'faketxt.txt', ]; $fs = get_file_storage(); $fs->create_file_from_string($filerecordinline, 'fake txt contents 1.'); // Last slot. $prefix = $quba->get_field_prefix(3); $data = [ ['name' => 'slots', 'value' => 3], ['name' => $prefix . ':sequencecheck', 'value' => $attemptobj->get_question_attempt(1)->get_sequence_check_count()], ['name' => $prefix . 'answer', 'value' => 'Some test'], ['name' => $prefix . 'answerformat', 'value' => FORMAT_HTML], ['name' => $prefix . 'attachments', 'value' => $draftitemid], ]; $result = mod_quiz_external::process_attempt($attempt->id, $data); $result = external_api::clean_returnvalue(mod_quiz_external::process_attempt_returns(), $result); $this->assertEquals(quiz_attempt::IN_PROGRESS, $result['state']); // Now, get the summary. $result = mod_quiz_external::get_attempt_summary($attempt->id); $result = external_api::clean_returnvalue(mod_quiz_external::get_attempt_summary_returns(), $result); $this->assertEquals('complete', $result['questions'][0]['state']); $this->assertEquals('complete', $result['questions'][1]['state']); $this->assertEquals('complete', $result['questions'][2]['state']); $this->assertFalse($result['questions'][0]['flagged']); $this->assertTrue($result['questions'][1]['flagged']); $this->assertFalse($result['questions'][2]['flagged']); // Check submitted files are there. $this->assertCount(1, $result['questions'][2]['responsefileareas']); $this->assertEquals('attachments', $result['questions'][2]['responsefileareas'][0]['area']); $this->assertCount(1, $result['questions'][2]['responsefileareas'][0]['files']); $this->assertEquals($filerecordinline['filename'], $result['questions'][2]['responsefileareas'][0]['files'][0]['filename']); // Finish the attempt. $sink = $this->redirectMessages(); $result = mod_quiz_external::process_attempt($attempt->id, [], true); $result = external_api::clean_returnvalue(mod_quiz_external::process_attempt_returns(), $result); $this->assertEquals(quiz_attempt::FINISHED, $result['state']); $messages = $sink->get_messages(); $message = reset($messages); $sink->close(); // Test customdata. if (!empty($message->customdata)) { $customdata = json_decode($message->customdata); $this->assertEquals($quizobj->get_quizid(), $customdata->instance); $this->assertEquals($quizobj->get_cmid(), $customdata->cmid); $this->assertEquals($attempt->id, $customdata->attemptid); $this->assertObjectHasProperty('notificationiconurl', $customdata); } // Start new attempt. $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, 2, false, $timenow, false, $this->student->id); quiz_start_new_attempt($quizobj, $quba, $attempt, 2, $timenow); quiz_attempt_save_started($quizobj, $quba, $attempt); // Force grace period, attempt going to overdue. $quiz->timeclose = $timenow - 10; $quiz->graceperiod = 60; $quiz->overduehandling = 'graceperiod'; $DB->update_record('quiz', $quiz); $result = mod_quiz_external::process_attempt($attempt->id, []); $result = external_api::clean_returnvalue(mod_quiz_external::process_attempt_returns(), $result); $this->assertEquals(quiz_attempt::OVERDUE, $result['state']); // Force grace period for time limit. $quiz->timeclose = 0; $quiz->timelimit = 1; $quiz->graceperiod = 60; $quiz->overduehandling = 'graceperiod'; $DB->update_record('quiz', $quiz); $timenow = time(); $quba = \question_engine::make_questions_usage_by_activity('mod_quiz', $quizobj->get_context()); $quba->set_preferred_behaviour($quizobj->get_quiz()->preferredbehaviour); $attempt = quiz_create_attempt($quizobj, 3, 2, $timenow - 10, false, $this->student->id); quiz_start_new_attempt($quizobj, $quba, $attempt, 2, $timenow - 10); quiz_attempt_save_started($quizobj, $quba, $attempt); $result = mod_quiz_external::process_attempt($attempt->id, []); $result = external_api::clean_returnvalue(mod_quiz_external::process_attempt_returns(), $result); $this->assertEquals(quiz_attempt::OVERDUE, $result['state']); // New attempt. $timenow = time(); $quba = \question_engine::make_questions_usage_by_activity('mod_quiz', $quizobj->get_context()); $quba->set_preferred_behaviour($quizobj->get_quiz()->preferredbehaviour); $attempt = quiz_create_attempt($quizobj, 4, 3, $timenow, false, $this->student->id); quiz_start_new_attempt($quizobj, $quba, $attempt, 3, $timenow); quiz_attempt_save_started($quizobj, $quba, $attempt); // Force abandon. $quiz->timeclose = $timenow - HOURSECS; $DB->update_record('quiz', $quiz); $result = mod_quiz_external::process_attempt($attempt->id, []); $result = external_api::clean_returnvalue(mod_quiz_external::process_attempt_returns(), $result); $this->assertEquals(quiz_attempt::ABANDONED, $result['state']); } /** * Test validate_attempt_review */ public function test_validate_attempt_review(): void { global $DB; // Create a new quiz with one attempt started. list($quiz, $context, $quizobj, $attempt, $attemptobj) = $this->create_quiz_with_questions(true); $this->setUser($this->student); // Invalid attempt, invalid id. try { $params = ['attemptid' => -1]; testable_mod_quiz_external::validate_attempt_review($params); $this->fail('Exception expected due invalid id.'); } catch (\dml_missing_record_exception $e) { $this->assertEquals('invalidrecord', $e->errorcode); } // Invalid attempt, not closed. try { $params = ['attemptid' => $attempt->id]; testable_mod_quiz_external::validate_attempt_review($params); $this->fail('Exception expected due not closed attempt.'); } catch (moodle_exception $e) { $this->assertEquals('attemptclosed', $e->errorcode); } // Test ok case (finished attempt). list($quiz, $context, $quizobj, $attempt, $attemptobj) = $this->create_quiz_with_questions(true, true); $params = ['attemptid' => $attempt->id]; testable_mod_quiz_external::validate_attempt_review($params); // Teacher should be able to view the review of one student's attempt. $this->setUser($this->teacher); testable_mod_quiz_external::validate_attempt_review($params); // We should not see other students attempts. $anotherstudent = self::getDataGenerator()->create_user(); $this->getDataGenerator()->enrol_user($anotherstudent->id, $this->course->id, $this->studentrole->id, 'manual'); $this->setUser($anotherstudent); try { $params = ['attemptid' => $attempt->id]; testable_mod_quiz_external::validate_attempt_review($params); $this->fail('Exception expected due missing permissions.'); } catch (moodle_exception $e) { $this->assertEquals('noreviewattempt', $e->errorcode); } } /** * Test get_attempt_review */ public function test_get_attempt_review(): void { global $DB; // Create a new quiz with two questions and one attempt finished. [$quiz, , , $attempt] = $this->create_quiz_with_questions(true, true); // Add feedback to the quiz. $feedback = new \stdClass(); $feedback->quizid = $quiz->id; $feedback->feedbacktext = 'Feedback text 1'; $feedback->feedbacktextformat = 1; $feedback->mingrade = 49; $feedback->maxgrade = 100; $feedback->id = $DB->insert_record('quiz_feedback', $feedback); $feedback->feedbacktext = 'Feedback text 2'; $feedback->feedbacktextformat = 1; $feedback->mingrade = 30; $feedback->maxgrade = 48; $feedback->id = $DB->insert_record('quiz_feedback', $feedback); $result = mod_quiz_external::get_attempt_review($attempt->id); $result = external_api::clean_returnvalue(mod_quiz_external::get_attempt_review_returns(), $result); // Two questions, one completed and correct, the other gave up. $this->assertEquals(50, $result['grade']); $this->assertEquals(1, $result['attempt']['attempt']); $this->assertEquals('finished', $result['attempt']['state']); $this->assertEquals(1, $result['attempt']['sumgrades']); $this->assertCount(2, $result['questions']); $this->assertEquals('gradedright', $result['questions'][0]['state']); $this->assertEquals(1, $result['questions'][0]['slot']); $this->assertEquals('gaveup', $result['questions'][1]['state']); $this->assertEquals(2, $result['questions'][1]['slot']); // Only first page. $result = mod_quiz_external::get_attempt_review($attempt->id, 0); $result = external_api::clean_returnvalue(mod_quiz_external::get_attempt_review_returns(), $result); $this->assertEquals(50, $result['grade']); $this->assertEquals(1, $result['attempt']['attempt']); $this->assertEquals('finished', $result['attempt']['state']); $this->assertEquals(1, $result['attempt']['sumgrades']); $this->assertCount(1, $result['questions']); $this->assertEquals('gradedright', $result['questions'][0]['state']); $this->assertEquals(1, $result['questions'][0]['slot']); $this->assertCount(1, $result['additionaldata']); $this->assertEquals('feedback', $result['additionaldata'][0]['id']); $this->assertEquals('Feedback', $result['additionaldata'][0]['title']); $this->assertEquals('Feedback text 1', $result['additionaldata'][0]['content']); } /** * Test get_attempt_review */ public function test_get_attempt_review_with_extra_grades(): void { global $DB; // Create a new quiz with two questions and one attempt finished. $this->setUser($this->student); [, , , $attempt, $attemptobj] = $this->create_quiz_with_questions(true, true); // Add some extra grade items. $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz'); $listeninggrade = $quizgenerator->create_grade_item(['quizid' => $attemptobj->get_quizid(), 'name' => 'Listening']); $readinggrade = $quizgenerator->create_grade_item(['quizid' => $attemptobj->get_quizid(), 'name' => 'Reading']); $structure = $attemptobj->get_quizobj()->get_structure(); $structure->update_slot_grade_item($structure->get_slot_by_number(1), $listeninggrade->id); $structure->update_slot_grade_item($structure->get_slot_by_number(2), $readinggrade->id); $result = mod_quiz_external::get_attempt_review($attempt->id); $result = external_api::clean_returnvalue(mod_quiz_external::get_attempt_review_returns(), $result); // Two questions, one completed and correct, the other gave up. $this->assertEquals(50, $result['grade']); $this->assertEquals(1, $result['attempt']['attempt']); $this->assertEquals('finished', $result['attempt']['state']); $this->assertEquals(1, $result['attempt']['sumgrades']); $this->assertCount(2, $result['questions']); $this->assertEquals('gradedright', $result['questions'][0]['state']); $this->assertEquals(1, $result['questions'][0]['slot']); $this->assertEquals('gaveup', $result['questions'][1]['state']); $this->assertEquals(2, $result['questions'][1]['slot']); // Only first page. $result = mod_quiz_external::get_attempt_review($attempt->id, 0); $result = external_api::clean_returnvalue(mod_quiz_external::get_attempt_review_returns(), $result); $this->assertEquals(50, $result['grade']); $this->assertEquals(1, $result['attempt']['attempt']); $this->assertEquals('finished', $result['attempt']['state']); $this->assertEquals(1, $result['attempt']['sumgrades']); $this->assertCount(1, $result['questions']); $this->assertEquals('gradedright', $result['questions'][0]['state']); $this->assertEquals(1, $result['questions'][0]['slot']); // Verify additional grades. $this->assertEquals(['name' => 'Listening', 'grade' => 1, 'maxgrade' => 1], $result['attempt']['gradeitemmarks'][0]); $this->assertEquals(['name' => 'Reading', 'grade' => 0, 'maxgrade' => 1], $result['attempt']['gradeitemmarks'][1]); // Now change the review options, so marks are not displayed, and check the result. $DB->set_field('quiz', 'reviewmarks', 0, ['id' => $attemptobj->get_quizid()]); $result = mod_quiz_external::get_attempt_review($attempt->id, 0); $result = external_api::clean_returnvalue(mod_quiz_external::get_attempt_review_returns(), $result); $this->assertEquals(1, $result['attempt']['attempt']); $this->assertEquals('finished', $result['attempt']['state']); $this->assertNull($result['attempt']['sumgrades']); $this->assertArrayNotHasKey('gradeitemmarks', $result['attempt']); } /** * Test test_view_attempt */ public function test_view_attempt(): void { global $DB; // Create a new quiz with two questions and one attempt started. list($quiz, $context, $quizobj, $attempt, $attemptobj, $quba) = $this->create_quiz_with_questions(true, false); // Test user with full capabilities. $this->setUser($this->student); // Trigger and capture the event. $sink = $this->redirectEvents(); $result = mod_quiz_external::view_attempt($attempt->id, 0); $result = external_api::clean_returnvalue(mod_quiz_external::view_attempt_returns(), $result); $this->assertTrue($result['status']); $events = $sink->get_events(); $this->assertCount(1, $events); $event = array_shift($events); // Checking that the event contains the expected values. $this->assertInstanceOf('\mod_quiz\event\attempt_viewed', $event); $this->assertEquals($context, $event->get_context()); $this->assertEventContextNotUsed($event); $this->assertNotEmpty($event->get_name()); // Now, force the quiz with QUIZ_NAVMETHOD_SEQ (sequential) navigation method. $DB->set_field('quiz', 'navmethod', QUIZ_NAVMETHOD_SEQ, ['id' => $quiz->id]); // Quiz requiring preflightdata. $DB->set_field('quiz', 'password', 'abcdef', ['id' => $quiz->id]); $preflightdata = [["name" => "quizpassword", "value" => 'abcdef']]; // See next page. $result = mod_quiz_external::view_attempt($attempt->id, 1, $preflightdata); $result = external_api::clean_returnvalue(mod_quiz_external::view_attempt_returns(), $result); $this->assertTrue($result['status']); $events = $sink->get_events(); $this->assertCount(2, $events); // Try to go to previous page. try { mod_quiz_external::view_attempt($attempt->id, 0); $this->fail('Exception expected due to try to see a previous page.'); } catch (moodle_exception $e) { $this->assertEquals('Out of sequence access', $e->errorcode); } } /** * Test test_view_attempt_summary */ public function test_view_attempt_summary(): void { global $DB; // Create a new quiz with two questions and one attempt started. list($quiz, $context, $quizobj, $attempt, $attemptobj, $quba) = $this->create_quiz_with_questions(true, false); // Test user with full capabilities. $this->setUser($this->student); // Trigger and capture the event. $sink = $this->redirectEvents(); $result = mod_quiz_external::view_attempt_summary($attempt->id); $result = external_api::clean_returnvalue(mod_quiz_external::view_attempt_summary_returns(), $result); $this->assertTrue($result['status']); $events = $sink->get_events(); $this->assertCount(1, $events); $event = array_shift($events); // Checking that the event contains the expected values. $this->assertInstanceOf('\mod_quiz\event\attempt_summary_viewed', $event); $this->assertEquals($context, $event->get_context()); $moodlequiz = new \moodle_url('/mod/quiz/summary.php', ['attempt' => $attempt->id]); $this->assertEquals($moodlequiz, $event->get_url()); $this->assertEventContextNotUsed($event); $this->assertNotEmpty($event->get_name()); // Quiz requiring preflightdata. $DB->set_field('quiz', 'password', 'abcdef', ['id' => $quiz->id]); $preflightdata = [["name" => "quizpassword", "value" => 'abcdef']]; $result = mod_quiz_external::view_attempt_summary($attempt->id, $preflightdata); $result = external_api::clean_returnvalue(mod_quiz_external::view_attempt_summary_returns(), $result); $this->assertTrue($result['status']); } /** * Test test_view_attempt_summary */ public function test_view_attempt_review(): void { global $DB; // Create a new quiz with two questions and one attempt finished. list($quiz, $context, $quizobj, $attempt, $attemptobj, $quba) = $this->create_quiz_with_questions(true, true); // Test user with full capabilities. $this->setUser($this->student); // Trigger and capture the event. $sink = $this->redirectEvents(); $result = mod_quiz_external::view_attempt_review($attempt->id, 0); $result = external_api::clean_returnvalue(mod_quiz_external::view_attempt_review_returns(), $result); $this->assertTrue($result['status']); $events = $sink->get_events(); $this->assertCount(1, $events); $event = array_shift($events); // Checking that the event contains the expected values. $this->assertInstanceOf('\mod_quiz\event\attempt_reviewed', $event); $this->assertEquals($context, $event->get_context()); $moodlequiz = new \moodle_url('/mod/quiz/review.php', ['attempt' => $attempt->id]); $this->assertEquals($moodlequiz, $event->get_url()); $this->assertEventContextNotUsed($event); $this->assertNotEmpty($event->get_name()); } /** * Test get_quiz_feedback_for_grade */ public function test_get_quiz_feedback_for_grade(): void { global $DB; // Add feedback to the quiz. $feedback = new \stdClass(); $feedback->quizid = $this->quiz->id; $feedback->feedbacktext = 'Feedback text 1'; $feedback->feedbacktextformat = 1; $feedback->mingrade = 49; $feedback->maxgrade = 100; $feedback->id = $DB->insert_record('quiz_feedback', $feedback); // Add a fake inline image to the feedback text. $filename = 'shouldbeanimage.jpg'; $filerecordinline = [ 'contextid' => $this->context->id, 'component' => 'mod_quiz', 'filearea' => 'feedback', 'itemid' => $feedback->id, 'filepath' => '/', 'filename' => $filename, ]; $fs = get_file_storage(); $fs->create_file_from_string($filerecordinline, 'image contents (not really)'); $feedback->feedbacktext = 'Feedback text 2'; $feedback->feedbacktextformat = 1; $feedback->mingrade = 30; $feedback->maxgrade = 49; $feedback->id = $DB->insert_record('quiz_feedback', $feedback); $result = mod_quiz_external::get_quiz_feedback_for_grade($this->quiz->id, 50); $result = external_api::clean_returnvalue(mod_quiz_external::get_quiz_feedback_for_grade_returns(), $result); $this->assertEquals('Feedback text 1', $result['feedbacktext']); $this->assertEquals($filename, $result['feedbackinlinefiles'][0]['filename']); $this->assertEquals(FORMAT_HTML, $result['feedbacktextformat']); $result = mod_quiz_external::get_quiz_feedback_for_grade($this->quiz->id, 30); $result = external_api::clean_returnvalue(mod_quiz_external::get_quiz_feedback_for_grade_returns(), $result); $this->assertEquals('Feedback text 2', $result['feedbacktext']); $this->assertEquals(FORMAT_HTML, $result['feedbacktextformat']); $result = mod_quiz_external::get_quiz_feedback_for_grade($this->quiz->id, 10); $result = external_api::clean_returnvalue(mod_quiz_external::get_quiz_feedback_for_grade_returns(), $result); $this->assertEquals('', $result['feedbacktext']); $this->assertEquals(FORMAT_MOODLE, $result['feedbacktextformat']); } /** * Test get_quiz_access_information */ public function test_get_quiz_access_information(): void { global $DB; // Create a new quiz. $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz'); $data = ['course' => $this->course->id]; $quiz = $quizgenerator->create_instance($data); $this->setUser($this->student); // Default restrictions (none). $result = mod_quiz_external::get_quiz_access_information($quiz->id); $result = external_api::clean_returnvalue(mod_quiz_external::get_quiz_access_information_returns(), $result); $expected = [ 'canattempt' => true, 'canmanage' => false, 'canpreview' => false, 'canreviewmyattempts' => true, 'canviewreports' => false, 'accessrules' => [], // This rule is always used, even if the quiz has no open or close date. 'activerulenames' => ['quizaccess_openclosedate'], 'preventaccessreasons' => [], 'warnings' => [] ]; $this->assertEquals($expected, $result); // Now teacher, different privileges. $this->setUser($this->teacher); $result = mod_quiz_external::get_quiz_access_information($quiz->id); $result = external_api::clean_returnvalue(mod_quiz_external::get_quiz_access_information_returns(), $result); $expected['canmanage'] = true; $expected['canpreview'] = true; $expected['canviewreports'] = true; $expected['canattempt'] = false; $expected['canreviewmyattempts'] = false; $this->assertEquals($expected, $result); $this->setUser($this->student); // Now add some restrictions. $quiz->timeopen = time() + DAYSECS; $quiz->timeclose = time() + WEEKSECS; $quiz->password = '123456'; $DB->update_record('quiz', $quiz); $result = mod_quiz_external::get_quiz_access_information($quiz->id); $result = external_api::clean_returnvalue(mod_quiz_external::get_quiz_access_information_returns(), $result); // Access is limited by time and password, but only the password limit has a description. $this->assertCount(1, $result['accessrules']); // Two rule names, password and open/close date. $this->assertCount(2, $result['activerulenames']); $this->assertCount(1, $result['preventaccessreasons']); } /** * Test get_attempt_access_information */ public function test_get_attempt_access_information(): void { global $DB; $this->setAdminUser(); // Create a new quiz with attempts. $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz'); $data = ['course' => $this->course->id, 'sumgrades' => 2]; $quiz = $quizgenerator->create_instance($data); // Create some questions. $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); $cat = $questiongenerator->create_question_category(); $question = $questiongenerator->create_question('numerical', null, ['category' => $cat->id]); quiz_add_quiz_question($question->id, $quiz); $question = $questiongenerator->create_question('shortanswer', null, ['category' => $cat->id]); quiz_add_quiz_question($question->id, $quiz); // Add new question types in the category (for the random one). $question = $questiongenerator->create_question('truefalse', null, ['category' => $cat->id]); $question = $questiongenerator->create_question('essay', null, ['category' => $cat->id]); $this->add_random_questions($quiz->id, 0, $cat->id, 1); $quizobj = quiz_settings::create($quiz->id, $this->student->id); // Set grade to pass. $item = \grade_item::fetch(['courseid' => $this->course->id, 'itemtype' => 'mod', 'itemmodule' => 'quiz', 'iteminstance' => $quiz->id, 'outcomeid' => null]); $item->gradepass = 80; $item->update(); $this->setUser($this->student); // Default restrictions (none). $result = mod_quiz_external::get_attempt_access_information($quiz->id); $result = external_api::clean_returnvalue(mod_quiz_external::get_attempt_access_information_returns(), $result); $expected = [ 'isfinished' => false, 'preventnewattemptreasons' => [], 'warnings' => [] ]; $this->assertEquals($expected, $result); // Limited attempts. $quiz->attempts = 1; $DB->update_record('quiz', $quiz); // Now, do one attempt. $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->student->id); quiz_start_new_attempt($quizobj, $quba, $attempt, 1, $timenow); quiz_attempt_save_started($quizobj, $quba, $attempt); // Process some responses from the student. $attemptobj = quiz_attempt::create($attempt->id); $tosubmit = [1 => ['answer' => '3.14']]; $attemptobj->process_submitted_actions($timenow, false, $tosubmit); // Finish the attempt. $attemptobj = quiz_attempt::create($attempt->id); $this->assertTrue($attemptobj->has_response_to_at_least_one_graded_question()); $attemptobj->process_submit($timenow, false); $attemptobj->process_grade_submission($timenow); // Can we start a new attempt? We shall not! $result = mod_quiz_external::get_attempt_access_information($quiz->id, $attempt->id); $result = external_api::clean_returnvalue(mod_quiz_external::get_attempt_access_information_returns(), $result); // Now new attemps allowed. $this->assertCount(1, $result['preventnewattemptreasons']); $this->assertFalse($result['ispreflightcheckrequired']); $this->assertEquals(get_string('nomoreattempts', 'quiz'), $result['preventnewattemptreasons'][0]); } /** * Test get_quiz_required_qtypes */ public function test_get_quiz_required_qtypes(): void { $this->setAdminUser(); // Create a new quiz. $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz'); $data = ['course' => $this->course->id]; $quiz = $quizgenerator->create_instance($data); // Create some questions. $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); $cat = $questiongenerator->create_question_category(); $question = $questiongenerator->create_question('numerical', null, ['category' => $cat->id]); quiz_add_quiz_question($question->id, $quiz); $question = $questiongenerator->create_question('shortanswer', null, ['category' => $cat->id]); quiz_add_quiz_question($question->id, $quiz); $question = $questiongenerator->create_question('truefalse', null, ['category' => $cat->id]); quiz_add_quiz_question($question->id, $quiz); $question = $questiongenerator->create_question('essay', null, ['category' => $cat->id]); quiz_add_quiz_question($question->id, $quiz); $question = $questiongenerator->create_question('multichoice', null, ['category' => $cat->id, 'status' => question_version_status::QUESTION_STATUS_DRAFT]); quiz_add_quiz_question($question->id, $quiz); $this->setUser($this->student); $result = mod_quiz_external::get_quiz_required_qtypes($quiz->id); $result = external_api::clean_returnvalue(mod_quiz_external::get_quiz_required_qtypes_returns(), $result); $expected = [ 'questiontypes' => ['essay', 'numerical', 'shortanswer', 'truefalse'], 'warnings' => [] ]; $this->assertEquals($expected, $result); } /** * Test get_quiz_required_qtypes for quiz with random questions */ public function test_get_quiz_required_qtypes_random(): void { $this->setAdminUser(); // Create a new quiz. $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz'); $quiz = $quizgenerator->create_instance(['course' => $this->course->id]); // Create some questions. $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); $cat = $questiongenerator->create_question_category(); $anothercat = $questiongenerator->create_question_category(); $question = $questiongenerator->create_question('numerical', null, ['category' => $cat->id]); $question = $questiongenerator->create_question('shortanswer', null, ['category' => $cat->id]); $question = $questiongenerator->create_question('truefalse', null, ['category' => $cat->id]); // Question in a different category. $question = $questiongenerator->create_question('essay', null, ['category' => $anothercat->id]); // Add a couple of random questions from the same category. $this->add_random_questions($quiz->id, 0, $cat->id, 1); $this->add_random_questions($quiz->id, 0, $cat->id, 1); $this->setUser($this->student); $result = mod_quiz_external::get_quiz_required_qtypes($quiz->id); $result = external_api::clean_returnvalue(mod_quiz_external::get_quiz_required_qtypes_returns(), $result); $expected = ['numerical', 'shortanswer', 'truefalse']; ksort($result['questiontypes']); $this->assertEquals($expected, $result['questiontypes']); // Add more questions to the quiz, this time from the other category. $this->setAdminUser(); $this->add_random_questions($quiz->id, 0, $anothercat->id, 1); $this->setUser($this->student); $result = mod_quiz_external::get_quiz_required_qtypes($quiz->id); $result = external_api::clean_returnvalue(mod_quiz_external::get_quiz_required_qtypes_returns(), $result); // The new question from the new category is returned as a potential random question for the quiz. $expected = ['essay', 'numerical', 'shortanswer', 'truefalse']; ksort($result['questiontypes']); $this->assertEquals($expected, $result['questiontypes']); } /** * Test that a sequential navigation quiz is not allowing to see questions in advance except if reviewing */ public function test_sequential_navigation_view_attempt(): void { // Test user with full capabilities. $quiz = $this->prepare_sequential_quiz(); $attemptobj = $this->create_quiz_attempt_object($quiz); $this->setUser($this->student); // Check out of sequence access for view. $this->assertNotEmpty(mod_quiz_external::view_attempt($attemptobj->get_attemptid(), 0, [])); try { mod_quiz_external::view_attempt($attemptobj->get_attemptid(), 3, []); $this->fail('Exception expected due to out of sequence access.'); } catch (moodle_exception $e) { $this->assertStringContainsString('quiz/Out of sequence access', $e->getMessage()); } } /** * Test that a sequential navigation quiz is not allowing to see questions content in advance for a student. */ public function test_sequential_navigation_attempt_summary(): void { // Test user with full capabilities. $quiz = $this->prepare_sequential_quiz(); $attemptobj = $this->create_quiz_attempt_object($quiz); $this->setUser($this->student); // Check that we do not return content from other questions except than the ones currently viewed. $result = mod_quiz_external::get_attempt_summary($attemptobj->get_attemptid()); $this->assertStringContainsString('Question (1)', $result['questions'][0]['html']); // Current question. $this->assertEmpty($result['questions'][1]['html']); // Next question. $this->assertEmpty($result['questions'][2]['html']); // And more. $this->assertEmpty($result['questions'][3]['html']); // And more. $this->assertEmpty($result['questions'][4]['html']); // And more. $this->assertNotContains('totalunanswered', $result); // For sequential quizzes, unanswered questions are not considered. } /** * Test that a sequential navigation quiz is not allowing to see questions in advance for student */ public function test_sequential_navigation_get_attempt_data(): void { // Test user with full capabilities. $quiz = $this->prepare_sequential_quiz(); $attemptobj = $this->create_quiz_attempt_object($quiz); $this->setUser($this->student); // Test invalid instance id. try { mod_quiz_external::get_attempt_data($attemptobj->get_attemptid(), 2); $this->fail('Exception expected due to out of sequence access.'); } catch (moodle_exception $e) { $this->assertStringContainsString('quiz/Out of sequence access', $e->getMessage()); } // Now we moved to page 1, we should see page 2 and 1 but not 0 or 3. $attemptobj->set_currentpage(1); // Test invalid instance id. try { mod_quiz_external::get_attempt_data($attemptobj->get_attemptid(), 0); $this->fail('Exception expected due to out of sequence access.'); } catch (moodle_exception $e) { $this->assertStringContainsString('quiz/Out of sequence access', $e->getMessage()); } try { mod_quiz_external::get_attempt_data($attemptobj->get_attemptid(), 3); $this->fail('Exception expected due to out of sequence access.'); } catch (moodle_exception $e) { $this->assertStringContainsString('quiz/Out of sequence access', $e->getMessage()); } // Now we can see page 1. $result = mod_quiz_external::get_attempt_data($attemptobj->get_attemptid(), 1); $this->assertCount(1, $result['questions']); $this->assertStringContainsString('Question (2)', $result['questions'][0]['html']); } /** * Prepare quiz for sequential navigation tests * * @return quiz_settings */ private function prepare_sequential_quiz(): quiz_settings { // Create a new quiz with 5 questions and one attempt started. // Create a new quiz with attempts. $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz'); $data = [ 'course' => $this->course->id, 'sumgrades' => 2, 'questionsperpage' => 1, 'preferredbehaviour' => 'deferredfeedback', 'navmethod' => QUIZ_NAVMETHOD_SEQ ]; $quiz = $quizgenerator->create_instance($data); // Now generate the questions. $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); $cat = $questiongenerator->create_question_category(); for ($pageindex = 1; $pageindex <= 5; $pageindex++) { $question = $questiongenerator->create_question('truefalse', null, [ 'category' => $cat->id, 'questiontext' => ['text' => "Question ($pageindex)"] ]); quiz_add_quiz_question($question->id, $quiz, $pageindex); } $quizobj = quiz_settings::create($quiz->id, $this->student->id); // Set grade to pass. $item = \grade_item::fetch(['courseid' => $this->course->id, 'itemtype' => 'mod', 'itemmodule' => 'quiz', 'iteminstance' => $quiz->id, 'outcomeid' => null]); $item->gradepass = 80; $item->update(); return $quizobj; } /** * Create question attempt * * @param quiz_settings $quizobj * @param int|null $userid * @param bool|null $ispreview * @return quiz_attempt * @throws moodle_exception */ private function create_quiz_attempt_object( quiz_settings $quizobj, ?int $userid = null, ?bool $ispreview = false ): quiz_attempt { global $USER; $timenow = time(); // Now, do one attempt. $quba = \question_engine::make_questions_usage_by_activity('mod_quiz', $quizobj->get_context()); $quba->set_preferred_behaviour($quizobj->get_quiz()->preferredbehaviour); $attemptnumber = 1; if (!empty($USER->id)) { $attemptnumber = count(quiz_get_user_attempts($quizobj->get_quizid(), $USER->id)) + 1; } $attempt = quiz_create_attempt($quizobj, $attemptnumber, false, $timenow, $ispreview, $userid ?? $this->student->id); quiz_start_new_attempt($quizobj, $quba, $attempt, $attemptnumber, $timenow); quiz_attempt_save_started($quizobj, $quba, $attempt); $attemptobj = quiz_attempt::create($attempt->id); return $attemptobj; } } PK9B\|'D S Stests/privacy/provider_test.phpnu[. /** * Privacy provider tests. * * @package mod_quiz * @copyright 2018 Andrew Nicols * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace mod_quiz\privacy; use core_privacy\local\metadata\collection; use core_privacy\local\request\deletion_criteria; use core_privacy\local\request\writer; use mod_quiz\privacy\provider; use mod_quiz\privacy\helper; use mod_quiz\quiz_attempt; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->dirroot . '/question/tests/privacy_helper.php'); /** * Privacy provider tests class. * * @package mod_quiz * @copyright 2018 Andrew Nicols * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @covers \mod_quiz\privacy\provider */ final class provider_test extends \core_privacy\tests\provider_testcase { use \core_question_privacy_helper; /** * Test that a user who has no data gets no contexts */ public function test_get_contexts_for_userid_no_data(): void { global $USER; $this->resetAfterTest(); $this->setAdminUser(); $contextlist = provider::get_contexts_for_userid($USER->id); $this->assertEmpty($contextlist); } /** * Test for provider::get_contexts_for_userid() when there is no quiz attempt at all. */ public function test_get_contexts_for_userid_no_attempt_with_override(): void { global $DB; $this->resetAfterTest(true); $course = $this->getDataGenerator()->create_course(); $user = $this->getDataGenerator()->create_user(); // Make a quiz with an override. $this->setUser(); $quiz = $this->create_test_quiz($course); $DB->insert_record('quiz_overrides', [ 'quiz' => $quiz->id, 'userid' => $user->id, 'timeclose' => 1300, 'timelimit' => null, ]); $cm = get_coursemodule_from_instance('quiz', $quiz->id); $context = \context_module::instance($cm->id); // Fetch the contexts - only one context should be returned. $this->setUser(); $contextlist = provider::get_contexts_for_userid($user->id); $this->assertCount(1, $contextlist); $this->assertEquals($context, $contextlist->current()); } /** * The export function should handle an empty contextlist properly. */ public function test_export_user_data_no_data(): void { global $USER; $this->resetAfterTest(); $this->setAdminUser(); $approvedcontextlist = new \core_privacy\tests\request\approved_contextlist( \core_user::get_user($USER->id), 'mod_quiz', [] ); provider::export_user_data($approvedcontextlist); $this->assertDebuggingNotCalled(); // No data should have been exported. $writer = \core_privacy\local\request\writer::with_context(\context_system::instance()); $this->assertFalse($writer->has_any_data_in_any_context()); } /** * The delete function should handle an empty contextlist properly. */ public function test_delete_data_for_user_no_data(): void { global $USER; $this->resetAfterTest(); $this->setAdminUser(); $approvedcontextlist = new \core_privacy\tests\request\approved_contextlist( \core_user::get_user($USER->id), 'mod_quiz', [] ); provider::delete_data_for_user($approvedcontextlist); $this->assertDebuggingNotCalled(); } /** * Export + Delete quiz data for a user who has made a single attempt. */ public function test_user_with_data(): void { global $DB; $this->resetAfterTest(true); $course = $this->getDataGenerator()->create_course(); $user = $this->getDataGenerator()->create_user(); $otheruser = $this->getDataGenerator()->create_user(); // Make a quiz with an override. $this->setUser(); $quiz = $this->create_test_quiz($course); $DB->insert_record('quiz_overrides', [ 'quiz' => $quiz->id, 'userid' => $user->id, 'timeclose' => 1300, 'timelimit' => null, ]); // Run as the user and make an attempt on the quiz. list($quizobj, $quba, $attemptobj) = $this->attempt_quiz($quiz, $user); $this->attempt_quiz($quiz, $otheruser); $context = $quizobj->get_context(); // Fetch the contexts - only one context should be returned. $this->setUser(); $contextlist = provider::get_contexts_for_userid($user->id); $this->assertCount(1, $contextlist); $this->assertEquals($context, $contextlist->current()); // Perform the export and check the data. $this->setUser($user); $approvedcontextlist = new \core_privacy\tests\request\approved_contextlist( \core_user::get_user($user->id), 'mod_quiz', $contextlist->get_contextids() ); provider::export_user_data($approvedcontextlist); // Ensure that the quiz data was exported correctly. /** @var \core_privacy\tests\request\content_writer $writer */ $writer = writer::with_context($context); $this->assertTrue($writer->has_any_data()); $quizdata = $writer->get_data([]); $this->assertEquals($quizobj->get_quiz_name(), $quizdata->name); // Every module has an intro. $this->assertTrue(isset($quizdata->intro)); // Fetch the attempt data. $attempt = $attemptobj->get_attempt(); $attemptsubcontext = [ get_string('attempts', 'mod_quiz'), $attempt->attempt, ]; $attemptdata = writer::with_context($context)->get_data($attemptsubcontext); $attempt = $attemptobj->get_attempt(); $this->assertTrue(isset($attemptdata->state)); $this->assertEquals(quiz_attempt::state_name($attemptobj->get_state()), $attemptdata->state); $this->assertTrue(isset($attemptdata->timestart)); $this->assertTrue(isset($attemptdata->timefinish)); $this->assertTrue(isset($attemptdata->timemodified)); $this->assertFalse(isset($attemptdata->timemodifiedoffline)); $this->assertFalse(isset($attemptdata->timecheckstate)); $this->assertTrue(isset($attemptdata->grade)); $this->assertEquals(100.00, $attemptdata->grade->grade); // Check that the exported question attempts are correct. $attemptsubcontext = helper::get_quiz_attempt_subcontext($attemptobj->get_attempt(), $user); $this->assert_question_attempt_exported( $context, $attemptsubcontext, \question_engine::load_questions_usage_by_activity($attemptobj->get_uniqueid()), quiz_get_review_options($quiz, $attemptobj->get_attempt(), $context), $user ); // Delete the data and check it is removed. $this->setUser(); provider::delete_data_for_user($approvedcontextlist); $this->expectException(\dml_missing_record_exception::class); quiz_attempt::create($attemptobj->get_quizid()); } /** * Export + Delete quiz data for a user who has made a single attempt. */ public function test_user_with_preview(): void { global $DB; $this->resetAfterTest(true); // Make a quiz. $course = $this->getDataGenerator()->create_course(); $user = $this->getDataGenerator()->create_user(); $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz'); $quiz = $quizgenerator->create_instance([ 'course' => $course->id, 'questionsperpage' => 0, 'grade' => 100.0, 'sumgrades' => 2, ]); // Create a couple of questions. $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); $cat = $questiongenerator->create_question_category(); $saq = $questiongenerator->create_question('shortanswer', null, ['category' => $cat->id]); quiz_add_quiz_question($saq->id, $quiz); $numq = $questiongenerator->create_question('numerical', null, ['category' => $cat->id]); quiz_add_quiz_question($numq->id, $quiz); // Run as the user and make an attempt on the quiz. $this->setUser($user); $starttime = time(); $quizobj = \mod_quiz\quiz_settings::create($quiz->id, $user->id); $context = $quizobj->get_context(); $quba = \question_engine::make_questions_usage_by_activity('mod_quiz', $quizobj->get_context()); $quba->set_preferred_behaviour($quizobj->get_quiz()->preferredbehaviour); // Start the attempt. $attempt = quiz_create_attempt($quizobj, 1, false, $starttime, true, $user->id); quiz_start_new_attempt($quizobj, $quba, $attempt, 1, $starttime); quiz_attempt_save_started($quizobj, $quba, $attempt); // Answer the questions. $attemptobj = quiz_attempt::create($attempt->id); $tosubmit = [ 1 => ['answer' => 'frog'], 2 => ['answer' => '3.14'], ]; $attemptobj->process_submitted_actions($starttime, false, $tosubmit); // Finish the attempt. $attemptobj = quiz_attempt::create($attempt->id); $this->assertTrue($attemptobj->has_response_to_at_least_one_graded_question()); $attemptobj->process_submit($starttime, false); $attemptobj->process_grade_submission($starttime); // Fetch the contexts - no context should be returned. $this->setUser(); $contextlist = provider::get_contexts_for_userid($user->id); $this->assertCount(0, $contextlist); } /** * Export + Delete quiz data for a user who has made a single attempt. */ public function test_delete_data_for_all_users_in_context(): void { global $DB; $this->resetAfterTest(true); $course = $this->getDataGenerator()->create_course(); $user = $this->getDataGenerator()->create_user(); $otheruser = $this->getDataGenerator()->create_user(); // Make a quiz with an override. $this->setUser(); $quiz = $this->create_test_quiz($course); $DB->insert_record('quiz_overrides', [ 'quiz' => $quiz->id, 'userid' => $user->id, 'timeclose' => 1300, 'timelimit' => null, ]); // Run as the user and make an attempt on the quiz. list($quizobj, $quba, $attemptobj) = $this->attempt_quiz($quiz, $user); list($quizobj, $quba, $attemptobj) = $this->attempt_quiz($quiz, $otheruser); // Create another quiz and questions, and repeat the data insertion. $this->setUser(); $otherquiz = $this->create_test_quiz($course); $DB->insert_record('quiz_overrides', [ 'quiz' => $otherquiz->id, 'userid' => $user->id, 'timeclose' => 1300, 'timelimit' => null, ]); // Run as the user and make an attempt on the quiz. list($otherquizobj, $otherquba, $otherattemptobj) = $this->attempt_quiz($otherquiz, $user); list($otherquizobj, $otherquba, $otherattemptobj) = $this->attempt_quiz($otherquiz, $otheruser); // Delete all data for all users in the context under test. $this->setUser(); $context = $quizobj->get_context(); provider::delete_data_for_all_users_in_context($context); // The quiz attempt should have been deleted from this quiz. $this->assertCount(0, $DB->get_records('quiz_attempts', ['quiz' => $quizobj->get_quizid()])); $this->assertCount(0, $DB->get_records('quiz_overrides', ['quiz' => $quizobj->get_quizid()])); $this->assertCount(0, $DB->get_records('question_attempts', ['questionusageid' => $quba->get_id()])); // But not for the other quiz. $this->assertNotCount(0, $DB->get_records('quiz_attempts', ['quiz' => $otherquizobj->get_quizid()])); $this->assertNotCount(0, $DB->get_records('quiz_overrides', ['quiz' => $otherquizobj->get_quizid()])); $this->assertNotCount(0, $DB->get_records('question_attempts', ['questionusageid' => $otherquba->get_id()])); } /** * Export + Delete quiz data for a user who has made a single attempt. */ public function test_wrong_context(): void { global $DB; $this->resetAfterTest(true); $course = $this->getDataGenerator()->create_course(); $user = $this->getDataGenerator()->create_user(); // Make a choice. $this->setUser(); $plugingenerator = $this->getDataGenerator()->get_plugin_generator('mod_choice'); $choice = $plugingenerator->create_instance(['course' => $course->id]); $cm = get_coursemodule_from_instance('choice', $choice->id); $context = \context_module::instance($cm->id); // Fetch the contexts - no context should be returned. $this->setUser(); $contextlist = provider::get_contexts_for_userid($user->id); $this->assertCount(0, $contextlist); // Perform the export and check the data. $this->setUser($user); $approvedcontextlist = new \core_privacy\tests\request\approved_contextlist( \core_user::get_user($user->id), 'mod_quiz', [$context->id] ); provider::export_user_data($approvedcontextlist); // Ensure that nothing was exported. /** @var \core_privacy\tests\request\content_writer $writer */ $writer = writer::with_context($context); $this->assertFalse($writer->has_any_data_in_any_context()); $this->setUser(); $dbwrites = $DB->perf_get_writes(); // Perform a deletion with the approved contextlist containing an incorrect context. $approvedcontextlist = new \core_privacy\tests\request\approved_contextlist( \core_user::get_user($user->id), 'mod_quiz', [$context->id] ); provider::delete_data_for_user($approvedcontextlist); $this->assertEquals($dbwrites, $DB->perf_get_writes()); $this->assertDebuggingNotCalled(); // Perform a deletion of all data in the context. provider::delete_data_for_all_users_in_context($context); $this->assertEquals($dbwrites, $DB->perf_get_writes()); $this->assertDebuggingNotCalled(); } /** * Create a test quiz for the specified course. * * @param \stdClass $course * @return \stdClass */ protected function create_test_quiz($course) { global $DB; $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz'); $quiz = $quizgenerator->create_instance([ 'course' => $course->id, 'questionsperpage' => 0, 'grade' => 100.0, 'sumgrades' => 2, ]); // Create a couple of questions. $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); $cat = $questiongenerator->create_question_category(); $saq = $questiongenerator->create_question('shortanswer', null, ['category' => $cat->id]); quiz_add_quiz_question($saq->id, $quiz); $numq = $questiongenerator->create_question('numerical', null, ['category' => $cat->id]); quiz_add_quiz_question($numq->id, $quiz); return $quiz; } /** * Answer questions for a quiz + user. * * @param \stdClass $quiz * @param \stdClass $user * @return array */ protected function attempt_quiz($quiz, $user) { $this->setUser($user); $starttime = time(); $quizobj = \mod_quiz\quiz_settings::create($quiz->id, $user->id); $context = $quizobj->get_context(); $quba = \question_engine::make_questions_usage_by_activity('mod_quiz', $quizobj->get_context()); $quba->set_preferred_behaviour($quizobj->get_quiz()->preferredbehaviour); // Start the attempt. $attempt = quiz_create_attempt($quizobj, 1, false, $starttime, false, $user->id); quiz_start_new_attempt($quizobj, $quba, $attempt, 1, $starttime); quiz_attempt_save_started($quizobj, $quba, $attempt); // Answer the questions. $attemptobj = quiz_attempt::create($attempt->id); $tosubmit = [ 1 => ['answer' => 'frog'], 2 => ['answer' => '3.14'], ]; $attemptobj->process_submitted_actions($starttime, false, $tosubmit); // Finish the attempt. $attemptobj = quiz_attempt::create($attempt->id); $attemptobj->process_submit($starttime, false); $attemptobj->process_grade_submission($starttime); $this->setUser(); return [$quizobj, $quba, $attemptobj]; } /** * Test for provider::get_users_in_context(). */ public function test_get_users_in_context(): void { global $DB; $this->resetAfterTest(true); $course = $this->getDataGenerator()->create_course(); $user = $this->getDataGenerator()->create_user(); $anotheruser = $this->getDataGenerator()->create_user(); $extrauser = $this->getDataGenerator()->create_user(); // Make a quiz. $this->setUser(); $quiz = $this->create_test_quiz($course); // Create an override for user1. $DB->insert_record('quiz_overrides', [ 'quiz' => $quiz->id, 'userid' => $user->id, 'timeclose' => 1300, 'timelimit' => null, ]); // Make an attempt on the quiz as user2. list($quizobj, $quba, $attemptobj) = $this->attempt_quiz($quiz, $anotheruser); $context = $quizobj->get_context(); // Fetch users - user1 and user2 should be returned. $userlist = new \core_privacy\local\request\userlist($context, 'mod_quiz'); provider::get_users_in_context($userlist); $this->assertEqualsCanonicalizing( [$user->id, $anotheruser->id], $userlist->get_userids()); } /** * Test for provider::delete_data_for_users(). */ public function test_delete_data_for_users(): void { global $DB; $this->resetAfterTest(true); $user1 = $this->getDataGenerator()->create_user(); $user2 = $this->getDataGenerator()->create_user(); $user3 = $this->getDataGenerator()->create_user(); $course1 = $this->getDataGenerator()->create_course(); $course2 = $this->getDataGenerator()->create_course(); // Make a quiz in each course. $quiz1 = $this->create_test_quiz($course1); $quiz2 = $this->create_test_quiz($course2); // Attempt quiz1 as user1 and user2. list($quiz1obj) = $this->attempt_quiz($quiz1, $user1); $this->attempt_quiz($quiz1, $user2); // Create an override in quiz1 for user3. $DB->insert_record('quiz_overrides', [ 'quiz' => $quiz1->id, 'userid' => $user3->id, 'timeclose' => 1300, 'timelimit' => null, ]); // Attempt quiz2 as user1. $this->attempt_quiz($quiz2, $user1); // Delete the data for user1 and user3 in course1 and check it is removed. $quiz1context = $quiz1obj->get_context(); $approveduserlist = new \core_privacy\local\request\approved_userlist($quiz1context, 'mod_quiz', [$user1->id, $user3->id]); provider::delete_data_for_users($approveduserlist); // Only the attempt of user2 should be remained in quiz1. $this->assertEquals( [$user2->id], $DB->get_fieldset_select('quiz_attempts', 'userid', 'quiz = ?', [$quiz1->id]) ); // The attempt that user1 made in quiz2 should be remained. $this->assertEquals( [$user1->id], $DB->get_fieldset_select('quiz_attempts', 'userid', 'quiz = ?', [$quiz2->id]) ); // The quiz override in quiz1 that we had for user3 should be deleted. $this->assertEquals( [], $DB->get_fieldset_select('quiz_overrides', 'userid', 'quiz = ?', [$quiz1->id]) ); } } PK9B\=5tests/quizobj_test.phpnu[. namespace mod_quiz; use basic_testcase; use mod_quiz\question\display_options; use mod_quiz\quiz_settings; use stdClass; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->dirroot . '/mod/quiz/locallib.php'); /** * Unit tests for the quiz class * * @package mod_quiz * @copyright 2008 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @covers \mod_quiz\quiz_settings */ final class quizobj_test extends basic_testcase { /** * Test cases for {@see test_cannot_review_message()}. * * @return array[] */ public static function cannot_review_message_testcases(): array { return [ // Review Time close // Later close quiz attempt When Expected // Quiz with no close date. [false, false, null, null, display_options::DURING, ''], [false, false, null, -60, display_options::IMMEDIATELY_AFTER, 'noreview'], [false, false, null, -180, display_options::LATER_WHILE_OPEN, 'noreview'], [false, false, null, -180, display_options::AFTER_CLOSE, 'noreview'], [false, true, null, null, display_options::DURING, ''], [false, true, null, -60, display_options::IMMEDIATELY_AFTER, 'noreview'], [false, true, null, -180, display_options::LATER_WHILE_OPEN, 'noreview'], [false, true, null, -180, display_options::AFTER_CLOSE, 'noreview'], // Quiz with a close in the future date, review only after close. [false, true, 300, null, display_options::DURING, ''], [false, true, 300, -60, display_options::IMMEDIATELY_AFTER, 300], [false, true, 300, -180, display_options::LATER_WHILE_OPEN, 300], // Quiz with a close in the future date, review later while open, or after close. [true, true, 300, null, display_options::DURING, ''], [true, true, 300, -60, display_options::IMMEDIATELY_AFTER, 60], [true, false, 300, -60, display_options::IMMEDIATELY_AFTER, 60], // Quiz with no closer date, review later while open. [true, false, 300, null, display_options::DURING, ''], [true, false, 300, -60, display_options::IMMEDIATELY_AFTER, 60], ]; } /** * Unit test for {@see quiz_settings::cannot_review_message()}. * * @dataProvider cannot_review_message_testcases * @param bool $reviewlater whether the quiz allows reivew 'later while the quiz is still open'. * @param bool $reviewafterclose whether the quiz allows rievew 'after the quiz is closed'. * @param int|null $quizcloseoffset quiz close date, relative to now. Null means not set. * @param int|null $attemptsubmitoffset quiz attempt sumbite time relative to now. Null means not submitted yet. * @param int $attemptstate current state of the attempt, one of the display_options constants. * @param string|int $expectation expected result: '' means '', 'noreview' means noreview lang string, * int means noreviewuntil with that time relative to now. */ public function test_cannot_review_message( bool $reviewlater, bool $reviewafterclose, ?int $quizcloseoffset, ?int $attemptsubmitoffset, int $attemptstate, string|int $expectation ): void { $quiz = new stdClass(); $now = time(); $cm = new stdClass(); $cm->id = 123; // Prepare quiz settings. $quiz->reviewattempt = display_options::DURING; if ($reviewlater) { $quiz->reviewattempt |= display_options::LATER_WHILE_OPEN; } if ($reviewafterclose) { $quiz->reviewattempt |= display_options::AFTER_CLOSE; } $quiz->attempts = 0; if ($quizcloseoffset === null) { $quiz->timeclose = 0; } else { $quiz->timeclose = $now + $quizcloseoffset; } if ($attemptsubmitoffset === null) { $submittime = 0; } else { $submittime = $now + $attemptsubmitoffset; } $quizobj = new quiz_settings($quiz, $cm, new stdClass(), false); // Prepare expected message. if ($expectation === 'noreview') { $expectation = get_string('noreview', 'quiz'); } else if (is_int($expectation)) { $expectation = get_string('noreviewuntil', 'quiz', userdate($now + $expectation)); } // Test. $this->assertEquals($expectation, $quizobj->cannot_review_message($attemptstate, false, $submittime)); } } PK9B\r%tests/local/override_manager_test.phpnu[. namespace mod_quiz\local; use mod_quiz\event\group_override_created; use mod_quiz\event\group_override_updated; use mod_quiz\event\user_override_created; use mod_quiz\event\user_override_updated; use mod_quiz\event\user_override_deleted; use mod_quiz\quiz_settings; /** * Test for override_manager class * * @package mod_quiz * @copyright 2024 Matthew Hilton * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @covers \mod_quiz\local\override_manager */ final class override_manager_test extends \advanced_testcase { /** @var array Default quiz settings **/ private const TEST_QUIZ_SETTINGS = [ 'attempts' => 5, 'timeopen' => 100000000, 'timeclose' => 10000001, 'timelimit' => 10, ]; /** * Create quiz and course for test * * @return array containing quiz object and course */ private function create_quiz_and_course(): array { $course = $this->getDataGenerator()->create_course(['groupmode' => SEPARATEGROUPS, 'groupmodeforce' => 1]); $quizparams = array_merge(self::TEST_QUIZ_SETTINGS, ['course' => $course->id]); $quiz = $this->getDataGenerator()->create_module('quiz', $quizparams); $quizobj = quiz_settings::create($quiz->id); return [$quizobj, $course]; } /** * Utility function that replaces the placeholders in the given data. * * @param array $data * @param array $placeholdervalues * @return array the $data with the placeholders replaced */ private function replace_placeholders(array $data, array $placeholdervalues) { foreach ($data as $key => $value) { $replacement = $placeholdervalues[$value] ?? null; if (!empty($replacement)) { $data[$key] = $replacement; } } return $data; } /** * Utility function that sets up data for tests testing CRUD operations. * Placeholders such as ':userid' and ':groupid' can be used in the data to replace with the relevant id. * * @param array $existingdata Data used to setup a preexisting quiz override record. * @param array $formdata submitted formdata * @return \stdClass containing formdata (after placeholders replaced), quizobj, user and group */ private function setup_existing_and_testing_data(array $existingdata, array $formdata): \stdClass { global $DB; [$quizobj, $course] = $this->create_quiz_and_course(); $user = $this->getDataGenerator()->create_user(); $this->getDataGenerator()->enrol_user($user->id, $course->id); $user2 = $this->getDataGenerator()->create_user(); $this->getDataGenerator()->enrol_user($user2->id, $course->id); $groupid = groups_create_group((object) ['courseid' => $course->id, 'name' => 'test']); $group2id = groups_create_group((object) ['courseid' => $course->id, 'name' => 'test2']); // Replace any userid or groupid placeholders in the form data or existing data. $placeholdervalues = [ ':userid' => $user->id, ':user2id' => $user2->id, ':groupid' => $groupid, ':group2id' => $group2id, ]; if (!empty($existingdata)) { // Raw insert the existing data for the test into the DB. // We assume it is valid for the test. $existingdata['quiz'] = $quizobj->get_quizid(); $existingid = $DB->insert_record('quiz_overrides', $this->replace_placeholders($existingdata, $placeholdervalues)); $placeholdervalues[':existingid'] = $existingid; } $formdata = $this->replace_placeholders($formdata, $placeholdervalues); // Add quiz id to formdata. $formdata['quiz'] = $quizobj->get_quizid(); return (object) [ 'quizobj' => $quizobj, 'formdata' => $formdata, 'user1' => $user, 'groupid1' => $groupid, ]; } /** * Data provider for {@see test_can_view_override} * * @return array[] */ public static function can_view_override_provider(): array { return [ ['admin', true, true, true, true], ['teacher', true, false, true, false], ]; } /** * Test whether user can view given override * * @param string $currentuser * @param bool $grouponeview * @param bool $grouptwoview * @param bool $studentoneview * @param bool $studenttwoview * * @dataProvider can_view_override_provider */ public function test_can_view_override( string $currentuser, bool $grouponeview, bool $grouptwoview, bool $studentoneview, bool $studenttwoview, ): void { global $DB; $this->resetAfterTest(); [$quizobj, $course] = $this->create_quiz_and_course(); // Teacher cannot view all groups. $roleid = $DB->get_field('role', 'id', ['shortname' => 'editingteacher']); assign_capability('moodle/site:accessallgroups', CAP_PROHIBIT, $roleid, $quizobj->get_context()->id); // Group one will contain our teacher and another student. $groupone = $this->getDataGenerator()->create_group(['courseid' => $course->id]); $teacher = $this->getDataGenerator()->create_and_enrol($course, 'editingteacher', ['username' => 'teacher']); $this->getDataGenerator()->create_group_member(['groupid' => $groupone->id, 'userid' => $teacher->id]); $studentone = $this->getDataGenerator()->create_and_enrol($course); $this->getDataGenerator()->create_group_member(['groupid' => $groupone->id, 'userid' => $studentone->id]); // Group two will contain a solitary student. $grouptwo = $this->getDataGenerator()->create_group(['courseid' => $course->id]); $studenttwo = $this->getDataGenerator()->create_and_enrol($course); $this->getDataGenerator()->create_group_member(['groupid' => $grouptwo->id, 'userid' => $studenttwo->id]); $user = \core_user::get_user_by_username($currentuser); $this->setUser($user); /** @var override_manager $manager */ $manager = $quizobj->get_override_manager(); $this->assertEquals($grouponeview, $manager->can_view_override( (object) ['groupid' => $groupone->id, 'userid' => null], $course, $quizobj->get_cm(), )); $this->assertEquals($grouptwoview, $manager->can_view_override( (object) ['groupid' => $grouptwo->id, 'userid' => null], $course, $quizobj->get_cm(), )); $this->assertEquals($studentoneview, $manager->can_view_override( (object) ['userid' => $studentone->id, 'groupid' => null], $course, $quizobj->get_cm(), )); $this->assertEquals($studenttwoview, $manager->can_view_override( (object) ['userid' => $studenttwo->id, 'groupid' => null], $course, $quizobj->get_cm(), )); } /** * Provides values to test_save_and_get_override * * @return array */ public static function save_and_get_override_provider(): array { return [ 'create user override - no existing data' => [ 'existingdata' => [], 'formdata' => [ 'userid' => ':userid', 'groupid' => null, 'timeopen' => 50, 'timeclose' => 51, 'timelimit' => 1, 'attempts' => 999, 'password' => 'test', ], 'expectedrecordscreated' => 1, 'expectedeventclass' => user_override_created::class, ], 'create user override - no calendar events should be created' => [ 'existingdata' => [], 'formdata' => [ 'userid' => ':userid', 'groupid' => null, 'timeopen' => null, 'timeclose' => null, 'timelimit' => 1, 'attempts' => 999, 'password' => 'test', ], 'expectedrecordscreated' => 1, 'expectedeventclass' => user_override_created::class, ], 'create user override - only timeopen' => [ 'existingdata' => [], 'formdata' => [ 'userid' => ':userid', 'groupid' => null, 'timeopen' => 50, 'timeclose' => null, 'timelimit' => null, 'attempts' => null, 'password' => 'test', ], 'expectedrecordscreated' => 1, 'expectedeventclass' => user_override_created::class, ], 'create group override - no existing data' => [ 'existingdata' => [], 'formdata' => [ 'userid' => null, 'groupid' => ':groupid', 'timeopen' => 50, 'timeclose' => 51, 'timelimit' => 1, 'attempts' => 999, 'password' => 'test', ], 'expectedrecordscreated' => 1, 'expectedeventclass' => group_override_created::class, ], 'create group override - no calendar events should be created' => [ 'existingdata' => [], 'formdata' => [ 'userid' => null, 'groupid' => ':groupid', 'timeopen' => null, 'timeclose' => null, 'timelimit' => 1, 'attempts' => 999, 'password' => 'test', ], 'expectedrecordscreated' => 1, 'expectedeventclass' => group_override_created::class, ], 'create group override - only timeopen' => [ 'existingdata' => [], 'formdata' => [ 'userid' => null, 'groupid' => ':groupid', 'timeopen' => 50, 'timeclose' => null, 'timelimit' => null, 'attempts' => null, 'password' => null, ], 'expectedrecordscreated' => 1, 'expectedeventclass' => group_override_created::class, ], 'update user override - updating existing data' => [ 'existingdata' => [ 'userid' => ':userid', 'groupid' => null, 'timeopen' => 50, 'timeclose' => 51, 'timelimit' => 2, 'attempts' => 2, 'password' => 'test2', ], 'formdata' => [ 'id' => ':existingid', 'userid' => ':userid', 'groupid' => null, 'timeopen' => 52, 'timeclose' => 53, 'timelimit' => 1, 'attempts' => 999, 'password' => 'test', ], 'expectedrecordscreated' => 0, 'expectedeventclass' => user_override_updated::class, ], 'update group override - updating existing data' => [ 'existingdata' => [ 'userid' => null, 'groupid' => ':groupid', 'timeopen' => 50, 'timeclose' => 51, 'timelimit' => 2, 'attempts' => 2, 'password' => 'test2', ], 'formdata' => [ 'id' => ':existingid', 'userid' => null, 'groupid' => ':groupid', 'timeopen' => 52, 'timeclose' => 53, 'timelimit' => 1, 'attempts' => 999, 'password' => 'test', ], 'expectedrecordscreated' => 0, 'expectedeventclass' => group_override_updated::class, ], 'attempts is set to unlimited (i.e. 0)' => [ 'existingdata' => [], 'formdata' => [ 'userid' => ':userid', 'groupid' => null, 'timeopen' => null, 'timeclose' => null, 'timelimit' => null, // This checks we are using empty() carefully, since this is valid. 'attempts' => 0, 'password' => null, ], 'expectedrecordscreated' => 1, 'expectedeventclass' => user_override_created::class, ], 'some settings submitted are the same as what is in the quiz (valid)' => [ 'existingdata' => [], 'formdata' => [ 'userid' => ':userid', 'groupid' => null, // Make these the same, they should be ignored. 'timeopen' => self::TEST_QUIZ_SETTINGS['timeopen'], 'timeclose' => self::TEST_QUIZ_SETTINGS['timeclose'], 'attempts' => self::TEST_QUIZ_SETTINGS['attempts'], // However change this, this should still get updated. 'timelimit' => self::TEST_QUIZ_SETTINGS['timelimit'] + 5, 'password' => null, ], 'expectedrecordscreated' => 1, 'expectedeventclass' => user_override_created::class, ], ]; } /** * Tests save_override function * * @param array $existingdata If given, an existing override will be created. * @param array $formdata The data being tested, simulating being submitted * @param int $expectedrecordscreated The number of records that are expected to be created by upsert * @param string $expectedeventclass an event class, which is expected to the emitted by upsert * @dataProvider save_and_get_override_provider */ public function test_save_and_get_override( array $existingdata, array $formdata, int $expectedrecordscreated, string $expectedeventclass ): void { global $DB; $this->setAdminUser(); $this->resetAfterTest(); $test = $this->setup_existing_and_testing_data($existingdata, $formdata); $manager = $test->quizobj->get_override_manager(); // Get the count before. $beforecount = $DB->count_records('quiz_overrides'); $sink = $this->redirectEvents(); // Submit the form data. $id = $manager->save_override($test->formdata); // Get the count after and compare to the expected. $aftercount = $DB->count_records('quiz_overrides'); $this->assertEquals($expectedrecordscreated, $aftercount - $beforecount); // Read back the created/updated value, and compare it to the formdata. $readback = $DB->get_record('quiz_overrides', ['id' => $id]); $this->assertNotEmpty($readback); foreach ($test->formdata as $key => $value) { // If the value is the same as the quiz, we expect it to be null. if (!empty(self::TEST_QUIZ_SETTINGS[$key]) && $value == self::TEST_QUIZ_SETTINGS[$key]) { $this->assertNull($readback->{$key}); } else { // Else we expect the value to have been set. $this->assertEquals($value, $readback->{$key}); } } // Check the get_all_overrides function returns this data as well. $alloverrideids = array_column($manager->get_all_overrides(), 'id'); $this->assertCount($aftercount, $alloverrideids); $this->assertTrue(in_array($id, $alloverrideids)); // Check that the calendar events are created as well. // This is only if the times were set, and they were set differently to the default. $expectedcount = 0; if (!empty($formdata['timeopen']) && $formdata['timeopen'] != self::TEST_QUIZ_SETTINGS['timeopen']) { $expectedcount += 1; } if (!empty($formdata['timeclose']) && $formdata['timeclose'] != self::TEST_QUIZ_SETTINGS['timeclose']) { $expectedcount += 1; } // Find all events. We assume the test event times do not exceed a time of 999. $events = calendar_get_events(0, 999, [$test->user1->id], [$test->groupid1], false); $this->assertCount($expectedcount, $events); // Check the expected event was also emitted. if (!empty($expectedeventclass)) { $events = $sink->get_events(); $eventclasses = array_map(fn($event) => get_class($event), $events); $this->assertTrue(in_array($expectedeventclass, $eventclasses)); } } /** * Tests that when saving an override, validation is performed and an exception is thrown if this fails. * Note - this does not test every validation scenario, for that {@see validate_data_provider} */ public function test_save_override_validation_throws_exception(): void { $this->setAdminUser(); $this->resetAfterTest(); [$quizobj] = $this->create_quiz_and_course(); $manager = $quizobj->get_override_manager(); // Submit empty (bad data). $this->expectException(\invalid_parameter_exception::class); $this->expectExceptionMessage(get_string('nooverridedata', 'quiz')); $manager->save_override([]); } /** * Provides values to test_validate_data * * @return array */ public static function validate_data_provider(): array { return [ 'valid create for user' => [ 'existingdata' => [], 'formdata' => [ 'userid' => ':userid', 'groupid' => null, 'timeopen' => 50, 'timeclose' => 51, 'timelimit' => 2, 'attempts' => 2, 'password' => 'test2', ], 'expectedreturn' => [], ], 'valid create for group' => [ 'existingdata' => [], 'formdata' => [ 'userid' => null, 'groupid' => ':groupid', 'timeopen' => 50, 'timeclose' => 51, 'timelimit' => 2, 'attempts' => 2, 'password' => 'test2', ], 'expectedreturn' => [], ], 'valid update for user' => [ 'existingdata' => [ 'userid' => ':userid', 'groupid' => null, 'timeopen' => 50, 'timeclose' => 51, 'timelimit' => 2, 'attempts' => 2, 'password' => 'test2', ], 'formdata' => [ 'id' => ':existingid', 'userid' => ':userid', 'groupid' => null, 'timeopen' => 50, 'timeclose' => 51, 'timelimit' => 2, 'attempts' => 2, 'password' => 'test2', ], 'expectedreturn' => [], ], 'valid update for group' => [ 'existingdata' => [ 'userid' => null, 'groupid' => ':groupid', 'timeopen' => 50, 'timeclose' => 51, 'timelimit' => 2, 'attempts' => 2, 'password' => 'test2', ], 'formdata' => [ 'id' => ':existingid', 'userid' => null, 'groupid' => ':groupid', 'timeopen' => 50, 'timeclose' => 51, 'timelimit' => 2, 'attempts' => 2, 'password' => 'test2', ], 'expectedreturn' => [], ], 'update but without user or group specified in update' => [ 'existingdata' => [ 'userid' => ':userid', 'groupid' => null, 'timeopen' => 50, 'timeclose' => 51, 'timelimit' => 2, 'attempts' => 2, 'password' => 'test2', ], 'formdata' => [ 'id' => ':existingid', 'userid' => null, 'groupid' => null, 'timeopen' => 52, 'timeclose' => 53, 'timelimit' => 1, 'attempts' => 999, 'password' => 'test', ], 'expectedreturn' => [ 'general' => get_string('overridemustsetuserorgroup', 'quiz'), ], ], 'both userid and groupid specified' => [ 'existingdata' => [], 'formdata' => [ 'userid' => ':userid', 'groupid' => ':groupid', 'timeopen' => 50, 'timeclose' => 100, 'timelimit' => null, 'attempts' => null, 'password' => null, ], 'expectedreturn' => [ 'general' => get_string('overridecannotsetbothgroupanduser', 'quiz'), ], ], 'neither userid nor groupid specified' => [ 'existingdata' => [], 'formdata' => [ 'userid' => null, 'groupid' => null, 'timeopen' => 50, 'timeclose' => 100, 'timelimit' => null, 'attempts' => null, 'password' => null, ], 'expectedreturn' => [ 'general' => get_string('overridemustsetuserorgroup', 'quiz'), ], ], 'empty data' => [ 'existingdata' => [], 'formdata' => [], 'expectedreturn' => [ 'general' => get_string('nooverridedata', 'quiz'), ], ], 'all nulls' => [ 'existingdata' => [], 'formdata' => [ 'userid' => null, 'groupid' => null, 'timeopen' => null, 'timeclose' => null, 'timelimit' => null, 'attempts' => null, 'password' => null, ], 'expectedreturn' => [ 'general' => get_string('nooverridedata', 'quiz'), ], ], 'user given, rest are nulls' => [ 'existingdata' => [], 'formdata' => [ 'userid' => ':userid', 'groupid' => null, 'timeopen' => null, 'timeclose' => null, 'timelimit' => null, 'attempts' => null, 'password' => null, ], 'expectedreturn' => [ 'general' => get_string('nooverridedata', 'quiz'), ], ], 'all submitted data was the same as the existing quiz' => [ 'existingdata' => [], 'formdata' => [ 'userid' => ':userid', 'groupid' => null, 'timeopen' => self::TEST_QUIZ_SETTINGS['timeopen'], 'timeclose' => self::TEST_QUIZ_SETTINGS['timeclose'], 'attempts' => self::TEST_QUIZ_SETTINGS['attempts'], 'timelimit' => self::TEST_QUIZ_SETTINGS['timelimit'], 'password' => null, ], 'expectedreturn' => [ 'general' => get_string('nooverridedata', 'quiz'), ], ], 'userid is invalid' => [ 'existingdata' => [], 'formdata' => [ 'userid' => -1, 'groupid' => null, 'timeopen' => null, 'timeclose' => null, 'timelimit' => null, 'attempts' => null, 'password' => 'mypass', ], 'expectedreturn' => [ 'userid' => get_string('overrideinvaliduser', 'quiz'), ], ], 'groupid is invalid' => [ 'existingdata' => [], 'formdata' => [ 'userid' => null, 'groupid' => -1, 'timeopen' => null, 'timeclose' => null, 'timelimit' => null, 'attempts' => null, 'password' => 'mypass', ], 'expectedreturn' => [ 'groupid' => get_string('overrideinvalidgroup', 'quiz'), ], ], 'timeclose is before timeopen' => [ 'existingdata' => [], 'formdata' => [ 'userid' => ':userid', 'groupid' => null, 'timeopen' => 50, 'timeclose' => 10, 'timelimit' => null, 'attempts' => null, 'password' => null, ], 'expectedreturn' => [ 'timeclose' => get_string('closebeforeopen', 'quiz'), ], ], 'timeclose is same as timeopen' => [ 'existingdata' => [], 'formdata' => [ 'userid' => ':userid', 'groupid' => null, 'timeopen' => 50, 'timeclose' => 50, 'timelimit' => null, 'attempts' => null, 'password' => null, ], 'expectedreturn' => [ 'timeclose' => get_string('closebeforeopen', 'quiz'), ], ], 'timelimit is negative' => [ 'existingdata' => [], 'formdata' => [ 'userid' => ':userid', 'groupid' => null, 'timeopen' => null, 'timeclose' => null, 'timelimit' => -1, 'attempts' => null, 'password' => null, ], 'expectedreturn' => [ 'timelimit' => get_string('overrideinvalidtimelimit', 'quiz'), ], ], 'attempts is negative' => [ 'existingdata' => [], 'formdata' => [ 'userid' => ':userid', 'groupid' => null, 'timeopen' => null, 'timeclose' => null, 'timelimit' => null, 'attempts' => -1, 'password' => null, ], 'expectedreturn' => [ 'attempts' => get_string('overrideinvalidattempts', 'quiz'), ], ], 'existing id given to update is invalid' => [ 'existingdata' => [], 'formdata' => [ 'id' => SQL_INT_MAX, 'userid' => ':userid', 'groupid' => null, 'timeopen' => 50, 'timeclose' => 51, 'timelimit' => null, 'attempts' => null, 'password' => null, ], 'expectedreturn' => [ 'general' => get_string('overrideinvalidexistingid', 'quiz'), ], ], 'userid changed after creation' => [ 'existingdata' => [ 'userid' => ":userid", 'groupid' => null, 'timeopen' => 50, 'timeclose' => 51, 'timelimit' => 2, 'attempts' => 2, 'password' => 'test2', ], 'formdata' => [ 'id' => ':existingid', 'userid' => ":user2id", 'groupid' => null, 'timeopen' => 50, 'timeclose' => 51, 'timelimit' => null, 'attempts' => null, 'password' => null, ], 'expectedreturn' => [ 'userid' => get_string('overridecannotchange', 'quiz'), ], ], 'groupid changed after creation' => [ 'existingdata' => [ 'userid' => null, 'groupid' => ':groupid', 'timeopen' => 50, 'timeclose' => 51, 'timelimit' => 2, 'attempts' => 2, 'password' => 'test2', ], 'formdata' => [ 'id' => ':existingid', 'userid' => null, 'groupid' => ':group2id', 'timeopen' => 50, 'timeclose' => 51, 'timelimit' => null, 'attempts' => null, 'password' => null, ], 'expectedreturn' => [ 'groupid' => get_string('overridecannotchange', 'quiz'), ], ], 'create multiple for the same user' => [ 'existingdata' => [ 'userid' => ':userid', 'groupid' => null, 'timeopen' => 50, 'timeclose' => 51, 'timelimit' => 2, 'attempts' => 2, 'password' => 'test2', ], 'formdata' => [ 'userid' => ':userid', 'groupid' => null, 'timeopen' => 50, 'timeclose' => 51, 'timelimit' => 2, 'attempts' => 2, 'password' => 'test2', ], 'expectedreturn' => [ 'general' => get_string('overridemultiplerecordsexist', 'quiz'), ], ], 'create multiple for the same group' => [ 'existingdata' => [ 'userid' => null, 'groupid' => ':groupid', 'timeopen' => 50, 'timeclose' => 51, 'timelimit' => 2, 'attempts' => 2, 'password' => 'test2', ], 'formdata' => [ 'userid' => null, 'groupid' => ':groupid', 'timeopen' => 50, 'timeclose' => 51, 'timelimit' => 2, 'attempts' => 2, 'password' => 'test2', ], 'expectedreturn' => [ 'general' => get_string('overridemultiplerecordsexist', 'quiz'), ], ], ]; } /** * Tests validate_data function * * @param array $existingdata If given, an existing override will be created. * @param array $formdata The data being tested, simulating being submitted * @param array $expectedreturn expected keys and associated values expected to be returned from validate_data * @dataProvider validate_data_provider */ public function test_validate_data(array $existingdata, array $formdata, array $expectedreturn): void { $this->setAdminUser(); $this->resetAfterTest(); // Setup the test. $test = $this->setup_existing_and_testing_data($existingdata, $formdata); // Validate. $manager = $test->quizobj->get_override_manager(); $result = $manager->validate_data($test->formdata); // Ensure all expected errors appear in the return. foreach ($expectedreturn as $key => $error) { // Ensure it is set. $this->assertContains($key, array_keys($result)); // Ensure the message contains the expected error. $this->assertStringContainsString($error, $result[$key]); } // Ensure there are no extra returned errors than what was expected. $extra = array_diff_key($result, $expectedreturn); $this->assertEmpty($extra, 'More validation errors were returned than expected'); } /** * Provide delete functions to test * * @return array */ public static function delete_override_provider(): array { return [ 'delete by id (no events logged)' => [ 'deletefunction' => fn($manager, $override) => $manager->delete_overrides_by_id([$override->id], false, false), 'checkeventslogged' => false, ], 'delete single (no events logged)' => [ 'deletefunction' => fn($manager, $override) => $manager->delete_overrides([$override], false, false), 'checkeventslogged' => false, ], 'delete all (no events logged)' => [ 'deletefunction' => fn($manager, $override) => $manager->delete_all_overrides(false, false), 'checkeventslogged' => false, ], 'delete by id (events logged)' => [ 'deletefunction' => fn($manager, $override) => $manager->delete_overrides_by_id([$override->id], true, false), 'checkeventslogged' => true, ], 'delete single (events logged)' => [ 'deletefunction' => fn($manager, $override) => $manager->delete_overrides([$override], true, false), 'checkeventslogged' => true, ], 'delete all (events logged)' => [ 'deletefunction' => fn($manager, $override) => $manager->delete_all_overrides(true, false), 'checkeventslogged' => true, ], 'delete all in database (events logged)' => [ 'deletefunction' => fn($manager, $override) => $manager->delete_all_overrides(true, false), 'checkeventslogged' => true, ], ]; } /** * Tests deleting override functions * * @param \Closure $deletefunction delete function to be called. * @param bool $checkeventslogged if true, will check that events were logged. * @dataProvider delete_override_provider */ public function test_delete_override(\Closure $deletefunction, bool $checkeventslogged): void { $this->setAdminUser(); $this->resetAfterTest(); [$quizobj] = $this->create_quiz_and_course(); $user = $this->getDataGenerator()->create_user(); // Create an override. $data = [ 'userid' => $user->id, 'timeopen' => 500, ]; $manager = $quizobj->get_override_manager(); $id = $manager->save_override($data); // Check the calendar event was made. $this->assertCount(1, calendar_get_events(0, 999, [$user->id], false, false)); // Check that the cache was made. $overridecache = new override_cache($quizobj->get_quizid()); $this->assertNotEmpty($overridecache->get_cached_user_override($user->id)); // Capture events. $sink = $this->redirectEvents(); $override = (object) [ 'id' => $id, 'userid' => $user->id, ]; // Delete the override. $deletefunction($manager, $override); // Check the calendar event was deleted. $this->assertCount(0, calendar_get_events(0, 999, [$user->id], false, false)); // Check that the cache was cleared. $this->assertEmpty($overridecache->get_cached_user_override($user->id)); // Check the event was logged. if ($checkeventslogged) { $events = $sink->get_events(); $eventclasses = array_map(fn($e) => get_class($e), $events); $this->assertTrue(in_array(user_override_deleted::class, $eventclasses)); } } /** * Creates a role with the given capabilities and assigns it to the user. * * @param int $userid user to assign role to * @param array $capabilities array of $capname => $permission to add to role */ private function give_user_role_with_capabilities(int $userid, array $capabilities): void { // Setup the role and permissions. $roleid = $this->getDataGenerator()->create_role(); foreach ($capabilities as $capname => $permission) { role_change_permission($roleid, \context_system::instance(), $capname, $permission); } $user = $this->getDataGenerator()->create_user(); role_assign($roleid, $userid, \context_system::instance()->id); } /** * Provides values to test_require_read_capability * * @return array */ public static function require_read_capability_provider(): array { $readfunc = fn($manager) => $manager->require_read_capability(); $managefunc = fn($manager) => $manager->require_manage_capability(); return [ 'reading - cannot read' => [ 'capabilitiestogive' => [], 'expectedallowed' => false, 'functionbeingtested' => $readfunc, ], 'reading - can read' => [ 'capabilitiestogive' => ['mod/quiz:viewoverrides' => CAP_ALLOW], 'expectedallowed' => true, 'functionbeingtested' => $readfunc, ], 'reading - can manage (so can also read)' => [ 'capabilitiestogive' => ['mod/quiz:manageoverrides' => CAP_ALLOW], 'expectedallowed' => true, 'functionbeingtested' => $readfunc, ], 'manage - cannot manage' => [ 'capabilitiestogive' => [], 'expectedallowed' => false, 'functionbeingtested' => $managefunc, ], 'manage - can only read' => [ 'capabilitiestogive' => ['mod/quiz:viewoverrides' => CAP_ALLOW], 'expectedallowed' => false, 'functionbeingtested' => $managefunc, ], 'manage - can manage' => [ 'capabilitiestogive' => ['mod/quiz:manageoverrides' => CAP_ALLOW], 'expectedallowed' => true, 'functionbeingtested' => $managefunc, ], ]; } /** * Tests require_read_capability * * @param array $capabilitiestogive array of capability => value to give to test user * @param bool $expectedallowed if false, will expect required_capability_exception to be thrown * @param \Closure $functionbeingtested is passed the manager and calls the function being tested (usually require_*_capability) * @dataProvider require_read_capability_provider */ public function test_require_read_capability( array $capabilitiestogive, bool $expectedallowed, \Closure $functionbeingtested ): void { $this->resetAfterTest(); [$quizobj] = $this->create_quiz_and_course(); $user = $this->getDataGenerator()->create_user(); $this->give_user_role_with_capabilities($user->id, $capabilitiestogive); $this->setUser($user); if (!$expectedallowed) { $this->expectException(\required_capability_exception::class); } $functionbeingtested($quizobj->get_override_manager()); } /** * Tests delete_orphaned_group_overrides_in_course */ public function test_delete_orphaned_group_overrides_in_course(): void { global $DB; $this->setAdminUser(); $this->resetAfterTest(); [$quizobj, $course] = $this->create_quiz_and_course(); // Create a two group and one user overrides. $groupid = groups_create_group((object) ['courseid' => $course->id, 'name' => 'test']); $groupdata = [ 'quiz' => $quizobj->get_quizid(), 'groupid' => $groupid, 'password' => 'test', ]; $group2id = groups_create_group((object) ['courseid' => $course->id, 'name' => 'test2']); $group2data = [ 'quiz' => $quizobj->get_quizid(), 'groupid' => $group2id, 'password' => 'test', ]; $userid = $this->getDataGenerator()->create_user()->id; $userdata = [ 'quiz' => $quizobj->get_quizid(), 'userid' => $userid, 'password' => 'test', ]; $manager = $quizobj->get_override_manager(); $manager->save_override($groupdata); $useroverrideid = $manager->save_override($userdata); $group2overrideid = $manager->save_override($group2data); $this->assertCount(3, $manager->get_all_overrides()); // Delete the first group (via the DB, so that the callbacks are not run). $DB->delete_records('groups', ['id' => $groupid]); // Confirm the overrides still exist (no callback has been run yet). $this->assertCount(3, $manager->get_all_overrides()); // Run orphaned record callback. override_manager::delete_orphaned_group_overrides_in_course($course->id); // Confirm it has now been deleted (but user and other group override still exists). $overrides = $manager->get_all_overrides(); $this->assertCount(2, $overrides); $this->assertArrayHasKey($useroverrideid, $overrides); $this->assertArrayHasKey($group2overrideid, $overrides); } /** * Tests deleting by id but providing an invalid id */ public function test_delete_by_id_invalid_id(): void { $this->setAdminUser(); $this->resetAfterTest(); [$quizobj] = $this->create_quiz_and_course(); $this->expectException(\invalid_parameter_exception::class); $this->expectExceptionMessage(get_string('overridemissingdelete', 'quiz', '0,1')); // These ids do not exist, so this should throw an error. $quizobj->get_override_manager()->delete_overrides_by_id([0, 1]); } /** * Tests that constructing a override manager with mismatching quiz and context throws an exception */ public function test_quiz_context_mismatch(): void { $this->resetAfterTest(); // Create one quiz for context, but make the quiz given have an incorrect cmid. [$quizobj] = $this->create_quiz_and_course(); $context = \context_module::instance($quizobj->get_cmid()); $quiz = (object)[ 'cmid' => $context->instanceid + 1, ]; $this->expectException(\coding_exception::class); $this->expectExceptionMessage("Given context does not match the quiz object"); new override_manager($quiz, $context); } } PK9B\x$` ` #tests/local/override_cache_test.phpnu[. namespace mod_quiz\local; /** * Cache manager tests for quiz overrides * * @package mod_quiz * @copyright 2024 Matthew Hilton * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @covers \mod_quiz\local\override_cache */ final class override_cache_test extends \advanced_testcase { /** * Tests CRUD functions of the override_cache */ public function test_crud(): void { // Cache is normally protected, but for testing we reflect it and put test data into it. $overridecache = new override_cache(0); $reflection = new \ReflectionClass($overridecache); $getcache = $reflection->getMethod('get_cache'); $cache = $getcache->invoke($overridecache); $getuserkey = $reflection->getMethod('get_user_cache_key'); $getgroupkey = $reflection->getMethod('get_group_cache_key'); $dummydata = (object)[ 'userid' => 1234, ]; // Set some data. $cache->set($getuserkey->invoke($overridecache, 123), $dummydata); $cache->set($getgroupkey->invoke($overridecache, 456), $dummydata); // Get the data back. $this->assertEquals($dummydata, $overridecache->get_cached_user_override(123)); $this->assertEquals($dummydata, $overridecache->get_cached_group_override(456)); // Delete. $overridecache->clear_for_user(123); $overridecache->clear_for_group(456); $this->assertEmpty($overridecache->get_cached_user_override(123)); $this->assertEmpty($overridecache->get_cached_group_override(456)); // Put some data back. $cache->set($getuserkey->invoke($overridecache, 123), $dummydata); $cache->set($getgroupkey->invoke($overridecache, 456), $dummydata); // Clear it. $overridecache->clear_for(123, 456); $this->assertEmpty($overridecache->get_cached_user_override(123)); $this->assertEmpty($overridecache->get_cached_group_override(456)); // Put some data back. $cache->set($getuserkey->invoke($overridecache, 123), 'testuser'); $cache->set($getgroupkey->invoke($overridecache, 456), 'testgroup'); // Purge it. \cache_helper::purge_by_event(override_cache::INVALIDATION_USERDATARESET); $this->assertEmpty($overridecache->get_cached_user_override(123)); $this->assertEmpty($overridecache->get_cached_group_override(456)); } } PK9B\$nH߇%%tests/reportlib_test.phpnu[. namespace mod_quiz; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->dirroot . '/mod/quiz/report/reportlib.php'); /** * This class contains the test cases for the functions in reportlib.php. * * @package mod_quiz * @category test * @copyright 2008 Jamie Pratt me@jamiep.org * @license http://www.gnu.org/copyleft/gpl.html GNU Public License */ final class reportlib_test extends \advanced_testcase { public function test_quiz_report_index_by_keys(): void { $datum = []; $object = new \stdClass(); $object->qid = 3; $object->aid = 101; $object->response = ''; $object->grade = 3; $datum[] = $object; $indexed = quiz_report_index_by_keys($datum, ['aid', 'qid']); $this->assertEquals($indexed[101][3]->qid, 3); $this->assertEquals($indexed[101][3]->aid, 101); $this->assertEquals($indexed[101][3]->response, ''); $this->assertEquals($indexed[101][3]->grade, 3); $indexed = quiz_report_index_by_keys($datum, ['aid', 'qid'], false); $this->assertEquals($indexed[101][3][0]->qid, 3); $this->assertEquals($indexed[101][3][0]->aid, 101); $this->assertEquals($indexed[101][3][0]->response, ''); $this->assertEquals($indexed[101][3][0]->grade, 3); } public function test_quiz_report_scale_summarks_as_percentage(): void { $quiz = new \stdClass(); $quiz->sumgrades = 10; $quiz->decimalpoints = 2; $this->assertEquals('12.34567%', quiz_report_scale_summarks_as_percentage(1.234567, $quiz, false)); $this->assertEquals('12.35%', quiz_report_scale_summarks_as_percentage(1.234567, $quiz, true)); $this->assertEquals('-', quiz_report_scale_summarks_as_percentage('-', $quiz, true)); } public function test_quiz_report_qm_filter_select_only_one_attempt_allowed(): void { $quiz = new \stdClass(); $quiz->attempts = 1; $this->assertSame('', quiz_report_qm_filter_select($quiz)); } public function test_quiz_report_qm_filter_select_average(): void { $quiz = new \stdClass(); $quiz->attempts = 10; $quiz->grademethod = QUIZ_GRADEAVERAGE; $this->assertSame('', quiz_report_qm_filter_select($quiz)); } public function test_quiz_report_qm_filter_select_first_last_best(): void { global $DB; $this->resetAfterTest(); $fakeattempt = new \stdClass(); $fakeattempt->userid = 123; $fakeattempt->quiz = 456; $fakeattempt->layout = '1,2,0,3,4,0,5'; $fakeattempt->state = quiz_attempt::FINISHED; // We intentionally insert these in a funny order, to test the SQL better. // The test data is: // id | quizid | user | attempt | sumgrades | state // --------------------------------------------------- // 4 | 456 | 123 | 1 | null | submitted // 2 | 456 | 123 | 2 | 50 | finished // 1 | 456 | 123 | 3 | 50 | finished // 3 | 456 | 123 | 4 | null | inprogress // 5 | 456 | 1 | 1 | 100 | finished // 6 | 456 | 234 | 1 | 100 | finished // 7 | 456 | 234 | 2 | null | submitted // layout is only given because it has a not-null constraint. // uniqueid values are meaningless, but that column has a unique constraint. // user 123 will have their first attempt submitted, but not finished. // user 234 will have their last attempt submitted, but not finished. $fakeattempt->attempt = 3; $fakeattempt->sumgrades = 50; $fakeattempt->uniqueid = 13; $DB->insert_record('quiz_attempts', $fakeattempt); $fakeattempt->attempt = 2; $fakeattempt->sumgrades = 50; $fakeattempt->uniqueid = 26; $fakeattempt->state = quiz_attempt::FINISHED; $DB->insert_record('quiz_attempts', $fakeattempt); $fakeattempt->attempt = 4; $fakeattempt->sumgrades = null; $fakeattempt->uniqueid = 39; $fakeattempt->state = quiz_attempt::IN_PROGRESS; $DB->insert_record('quiz_attempts', $fakeattempt); $fakeattempt->attempt = 1; $fakeattempt->sumgrades = null; $fakeattempt->uniqueid = 52; $fakeattempt->state = quiz_attempt::SUBMITTED; $DB->insert_record('quiz_attempts', $fakeattempt); $fakeattempt->attempt = 1; $fakeattempt->userid = 1; $fakeattempt->sumgrades = 100; $fakeattempt->uniqueid = 65; $fakeattempt->state = quiz_attempt::FINISHED; $DB->insert_record('quiz_attempts', $fakeattempt); $fakeattempt->attempt = 1; $fakeattempt->userid = 234; $fakeattempt->sumgrades = 100; $fakeattempt->uniqueid = 99; $DB->insert_record('quiz_attempts', $fakeattempt); $fakeattempt->attempt = 2; $fakeattempt->userid = 234; $fakeattempt->sumgrades = null; $fakeattempt->uniqueid = 77; $fakeattempt->state = quiz_attempt::SUBMITTED; $DB->insert_record('quiz_attempts', $fakeattempt); $quiz = new \stdClass(); $quiz->attempts = 10; $quiz->grademethod = QUIZ_ATTEMPTFIRST; $firstattempt = $DB->get_records_sql(" SELECT * FROM {quiz_attempts} quiza WHERE userid = ? AND quiz = ? AND " . quiz_report_qm_filter_select($quiz), [123, 456]); $this->assertCount(1, $firstattempt); $firstattempt = reset($firstattempt); $this->assertEquals(1, $firstattempt->attempt); $this->assertEquals(quiz_attempt::SUBMITTED, $firstattempt->state); $firstattempt = $DB->get_records_sql(" SELECT * FROM {quiz_attempts} quiza WHERE userid = ? AND quiz = ? AND " . quiz_report_qm_filter_select($quiz), [234, 456]); $this->assertCount(1, $firstattempt); $firstattempt = reset($firstattempt); $this->assertEquals(1, $firstattempt->attempt); $this->assertEquals(quiz_attempt::FINISHED, $firstattempt->state); $quiz->grademethod = QUIZ_ATTEMPTLAST; $lastattempt = $DB->get_records_sql(" SELECT * FROM {quiz_attempts} quiza WHERE userid = ? AND quiz = ? AND " . quiz_report_qm_filter_select($quiz), [123, 456]); $this->assertCount(1, $lastattempt); $lastattempt = reset($lastattempt); $this->assertEquals(3, $lastattempt->attempt); $this->assertEquals(quiz_attempt::FINISHED, $lastattempt->state); $lastattempt = $DB->get_records_sql(" SELECT * FROM {quiz_attempts} quiza WHERE userid = ? AND quiz = ? AND " . quiz_report_qm_filter_select($quiz), [234, 456]); $this->assertCount(1, $lastattempt); $lastattempt = reset($lastattempt); $this->assertEquals(2, $lastattempt->attempt); $this->assertEquals(quiz_attempt::SUBMITTED, $lastattempt->state); $quiz->attempts = 0; $quiz->grademethod = QUIZ_GRADEHIGHEST; $bestattempt = $DB->get_records_sql(" SELECT * FROM {quiz_attempts} qa_alias WHERE userid = ? AND quiz = ? AND " . quiz_report_qm_filter_select($quiz, 'qa_alias'), [123, 456]); $this->assertCount(1, $bestattempt); $bestattempt = reset($bestattempt); $this->assertEquals(2, $bestattempt->attempt); $bestattempt = $DB->get_records_sql(" SELECT * FROM {quiz_attempts} qa_alias WHERE userid = ? AND quiz = ? AND " . quiz_report_qm_filter_select($quiz, 'qa_alias'), [234, 456]); $this->assertCount(1, $bestattempt); $bestattempt = reset($bestattempt); $this->assertEquals(1, $bestattempt->attempt); } public function test_quiz_results_never_below_zero(): void { global $DB; $this->resetAfterTest(); $quizid = 7; $fakegrade = new \stdClass(); $fakegrade->quiz = $quizid; // Have 5 test grades. $fakegrade->userid = 10; $fakegrade->grade = 6.66667; $DB->insert_record('quiz_grades', $fakegrade); $fakegrade->userid = 11; $fakegrade->grade = -2.86; $DB->insert_record('quiz_grades', $fakegrade); $fakegrade->userid = 12; $fakegrade->grade = 10.0; $DB->insert_record('quiz_grades', $fakegrade); $fakegrade->userid = 13; $fakegrade->grade = -5.0; $DB->insert_record('quiz_grades', $fakegrade); $fakegrade->userid = 14; $fakegrade->grade = 33.33333; $DB->insert_record('quiz_grades', $fakegrade); $data = quiz_report_grade_bands(5, 20, $quizid); $this->assertGreaterThanOrEqual(0, min(array_keys($data))); } } PK9B\r|$|$tests/qbank_helper_test.phpnu[. namespace mod_quiz; use core_question\local\bank\question_version_status; use mod_quiz\external\submit_question_version; use mod_quiz\question\bank\qbank_helper; defined('MOODLE_INTERNAL') || die(); require_once(__DIR__ . '/quiz_question_helper_test_trait.php'); /** * Qbank helper test for quiz. * * @package mod_quiz * @category test * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @coversDefaultClass \mod_quiz\question\bank\qbank_helper */ final class qbank_helper_test extends \advanced_testcase { use \quiz_question_helper_test_trait; /** * @var \stdClass test student user. */ protected $student; /** * Called before every test. */ public function setUp(): void { global $USER; parent::setUp(); $this->setAdminUser(); $this->course = $this->getDataGenerator()->create_course(); $this->student = $this->getDataGenerator()->create_user(); $this->user = $USER; } /** * Test reference records. * * @covers ::get_version_options */ public function test_reference_records(): void { $this->resetAfterTest(); $quiz = $this->create_test_quiz($this->course); // Test for questions from a different context. $context = \context_module::instance($quiz->cmid); // Create a couple of questions. /** @var \core_question_generator $questiongenerator */ $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); $cat = $questiongenerator->create_question_category(['contextid' => $context->id]); $numq = $questiongenerator->create_question('essay', null, ['category' => $cat->id, 'name' => 'This is the first version']); // Create two version. $questiongenerator->update_question($numq, null, ['name' => 'This is the second version']); $questiongenerator->update_question($numq, null, ['name' => 'This is the third version']); quiz_add_quiz_question($numq->id, $quiz); // Create the quiz object. $quizobj = \mod_quiz\quiz_settings::create($quiz->id); $quizobj->preload_questions(); $quizobj->load_questions(); $questions = $quizobj->get_questions(); $question = reset($questions); $structure = structure::create_for_quiz($quizobj); $slots = $structure->get_slots(); $slot = reset($slots); $this->assertEquals(3, count(qbank_helper::get_version_options($question->id))); $this->assertDebuggingCalled(); $this->assertEquals($question->id, qbank_helper::choose_question_for_redo( $quiz->id, $context, $slot->id, new \qubaid_list([]))); // Create another version. $questiongenerator->update_question($numq, null, ['name' => 'This is the latest version']); // Change to always latest. submit_question_version::execute($slot->id, 0); $quizobj->preload_questions(); $quizobj->load_questions(); $questions = $quizobj->get_questions(); $question = reset($questions); $this->assertEquals($question->id, qbank_helper::choose_question_for_redo( $quiz->id, $context, $slot->id, new \qubaid_list([]))); } /** * Test question structure data. * * @covers ::get_question_structure * @covers ::get_always_latest_version_question_ids */ public function test_get_question_structure(): void { $this->resetAfterTest(); // Create a quiz. $quiz = $this->create_test_quiz($this->course); $quizcontext = \context_module::instance(get_coursemodule_from_instance("quiz", $quiz->id, $this->course->id)->id); // Create a question in the quiz question bank. /** @var \core_question_generator $questiongenerator */ $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); $cat = $questiongenerator->create_question_category(['contextid' => $quizcontext->id]); $q = $questiongenerator->create_question('essay', null, ['category' => $cat->id, 'name' => 'This is the first version']); // Edit it to create a second and third version. $questiongenerator->update_question($q, null, ['name' => 'This is the second version']); $finalq = $questiongenerator->update_question($q, null, ['name' => 'This is the third version']); // Add the question to the quiz. quiz_add_quiz_question($q->id, $quiz); // Load the quiz object and check. $quizobj = \mod_quiz\quiz_settings::create($quiz->id); $quizobj->preload_questions(); $quizobj->load_questions(); $questions = $quizobj->get_questions(); $question = reset($questions); $this->assertEquals($finalq->id, $question->id); $structure = structure::create_for_quiz($quizobj); $slots = $structure->get_slots(); $slot = reset($slots); $this->assertEquals($finalq->id, $slot->questionid); } /** * When a question only has draft versions, we should get those and not a dummy question. * * @return void * @covers ::get_question_structure */ public function test_get_question_structure_with_drafts(): void { $this->resetAfterTest(); // Create a quiz. $quiz = $this->create_test_quiz($this->course); $quizcontext = \context_module::instance(get_coursemodule_from_instance("quiz", $quiz->id, $this->course->id)->id); // Create some questions with drafts in the quiz question bank. /** @var \core_question_generator $questiongenerator */ $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); $cat = $questiongenerator->create_question_category(['contextid' => $quizcontext->id]); $q1 = $questiongenerator->create_question('essay', null, ['category' => $cat->id, 'name' => 'This is q1 the first version']); $q2 = $questiongenerator->create_question('essay', null, ['category' => $cat->id, 'name' => 'This is q2 the first version', 'status' => question_version_status::QUESTION_STATUS_DRAFT]); $q3 = $questiongenerator->create_question('essay', null, ['category' => $cat->id, 'name' => 'This is q3 the first version', 'status' => question_version_status::QUESTION_STATUS_DRAFT]); // Create a new draft version of a question. $q1final = $questiongenerator->update_question(clone $q1, null, ['name' => 'This is q1 the second version', 'status' => question_version_status::QUESTION_STATUS_DRAFT]); $q3final = $questiongenerator->update_question(clone $q3, null, ['name' => 'This is q3 the second version', 'status' => question_version_status::QUESTION_STATUS_DRAFT]); // Add the questions to the quiz. quiz_add_quiz_question($q1->id, $quiz); quiz_add_quiz_question($q2->id, $quiz); quiz_add_quiz_question($q3->id, $quiz); // Load the quiz object and check. $quizobj = \mod_quiz\quiz_settings::create($quiz->id); $quizobj->preload_questions(); $quizobj->load_questions(); $questions = $quizobj->get_questions(); $this->assertCount(3, $questions); // When a question has a Ready version, we should get that and not he draft. $this->assertTrue(array_key_exists($q1->id, $questions)); $this->assertFalse(array_key_exists($q1final->id, $questions)); $this->assertEquals(question_version_status::QUESTION_STATUS_READY, $questions[$q1->id]->status); $this->assertEquals('essay', $questions[$q1->id]->qtype); // When a question only has a draft, we should get that. $this->assertTrue(array_key_exists($q2->id, $questions)); $this->assertEquals(question_version_status::QUESTION_STATUS_DRAFT, $questions[$q2->id]->status); $this->assertEquals('essay', $questions[$q2->id]->qtype); // When a question has several versions but all draft, we should get the latest draft. $this->assertFalse(array_key_exists($q3->id, $questions)); $this->assertTrue(array_key_exists($q3final->id, $questions)); $this->assertEquals(question_version_status::QUESTION_STATUS_DRAFT, $questions[$q3final->id]->status); $this->assertEquals('essay', $questions[$q3final->id]->qtype); } } PK9B\wtests/restore_override_test.phpnu[. namespace mod_quiz; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->libdir . "/phpunit/classes/restore_date_testcase.php"); /** * Restore override tests. * * @package mod_quiz * @author 2019 Nathan Nguyen * @copyright Catalyst IT * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class restore_override_test extends \restore_date_testcase { /** * Test restore overrides. */ public function test_restore_overrides(): void { global $DB, $USER; $this->resetAfterTest(); $course = $this->getDataGenerator()->create_course(); $quizgen = $this->getDataGenerator()->get_plugin_generator('mod_quiz'); $quiz = $quizgen->create_instance(['course' => $course->id]); $group1 = $this->getDataGenerator()->create_group(['courseid' => $course->id]); $group2 = $this->getDataGenerator()->create_group(['courseid' => $course->id]); $now = 100; // Group overrides. $groupoverride1 = (object)[ 'quiz' => $quiz->id, 'groupid' => $group1->id, 'timeopen' => $now, 'timeclose' => $now + 20 ]; $DB->insert_record('quiz_overrides', $groupoverride1); $groupoverride2 = (object)[ 'quiz' => $quiz->id, 'groupid' => $group2->id, 'timeopen' => $now, 'timeclose' => $now + 40 ]; $DB->insert_record('quiz_overrides', $groupoverride2); // Current quiz overrides. $overrides = $DB->get_records('quiz_overrides', ['quiz' => $quiz->id]); $this->assertEquals(2, count($overrides)); // User Override. $useroverride = (object)[ 'quiz' => $quiz->id, 'userid' => $USER->id, 'sortorder' => 1, 'timeopen' => 100, 'timeclose' => 200 ]; $DB->insert_record('quiz_overrides', $useroverride); // Current quiz overrides. $overrides = $DB->get_records('quiz_overrides', ['quiz' => $quiz->id]); $this->assertEquals(3, count($overrides)); // Back up and restore including group info and user info. set_config('backup_general_groups', 1, 'backup'); $newcourseid = $this->backup_and_restore($course); $newquiz = $DB->get_record('quiz', ['course' => $newcourseid]); $overrides = $DB->get_records('quiz_overrides', ['quiz' => $newquiz->id]); // 2 groups overrides and 1 user override. $this->assertEquals(3, count($overrides)); // Back up and restore with user info and without group info. set_config('backup_general_groups', 0, 'backup'); $newcourseid = $this->backup_and_restore($course); $newquiz = $DB->get_record('quiz', ['course' => $newcourseid]); $overrides = $DB->get_records('quiz_overrides', ['quiz' => $newquiz->id]); // 1 user override. $this->assertEquals(1, count($overrides)); } } PK9B\ atests/lib_test.phpnu[. /** * Unit tests for (some of) mod/quiz/locallib.php. * * @package mod_quiz * @category test * @copyright 2008 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU Public License */ namespace mod_quiz; use context_module; use core_external\external_api; use mod_quiz\quiz_settings; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->dirroot . '/mod/quiz/lib.php'); require_once($CFG->dirroot . '/mod/quiz/tests/quiz_question_helper_test_trait.php'); /** * @copyright 2008 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU Public License */ final class lib_test extends \advanced_testcase { use \quiz_question_helper_test_trait; public function test_quiz_has_grades(): void { $quiz = new \stdClass(); $quiz->grade = '100.0000'; $quiz->sumgrades = '100.0000'; $this->assertTrue(quiz_has_grades($quiz)); $quiz->sumgrades = '0.0000'; $this->assertFalse(quiz_has_grades($quiz)); $quiz->grade = '0.0000'; $this->assertFalse(quiz_has_grades($quiz)); $quiz->sumgrades = '100.0000'; $this->assertFalse(quiz_has_grades($quiz)); } public function test_quiz_format_grade(): void { $quiz = new \stdClass(); $quiz->decimalpoints = 2; $this->assertEquals(quiz_format_grade($quiz, 0.12345678), format_float(0.12, 2)); $this->assertEquals(quiz_format_grade($quiz, 0), format_float(0, 2)); $this->assertEquals(quiz_format_grade($quiz, 1.000000000000), format_float(1, 2)); $quiz->decimalpoints = 0; $this->assertEquals(quiz_format_grade($quiz, 0.12345678), '0'); } public function test_quiz_get_grade_format(): void { $quiz = new \stdClass(); $quiz->decimalpoints = 2; $this->assertEquals(quiz_get_grade_format($quiz), 2); $this->assertEquals($quiz->questiondecimalpoints, -1); $quiz->questiondecimalpoints = 2; $this->assertEquals(quiz_get_grade_format($quiz), 2); $quiz->decimalpoints = 3; $quiz->questiondecimalpoints = -1; $this->assertEquals(quiz_get_grade_format($quiz), 3); $quiz->questiondecimalpoints = 4; $this->assertEquals(quiz_get_grade_format($quiz), 4); } public function test_quiz_format_question_grade(): void { $quiz = new \stdClass(); $quiz->decimalpoints = 2; $quiz->questiondecimalpoints = 2; $this->assertEquals(quiz_format_question_grade($quiz, 0.12345678), format_float(0.12, 2)); $this->assertEquals(quiz_format_question_grade($quiz, 0), format_float(0, 2)); $this->assertEquals(quiz_format_question_grade($quiz, 1.000000000000), format_float(1, 2)); $quiz->decimalpoints = 3; $quiz->questiondecimalpoints = -1; $this->assertEquals(quiz_format_question_grade($quiz, 0.12345678), format_float(0.123, 3)); $this->assertEquals(quiz_format_question_grade($quiz, 0), format_float(0, 3)); $this->assertEquals(quiz_format_question_grade($quiz, 1.000000000000), format_float(1, 3)); $quiz->questiondecimalpoints = 4; $this->assertEquals(quiz_format_question_grade($quiz, 0.12345678), format_float(0.1235, 4)); $this->assertEquals(quiz_format_question_grade($quiz, 0), format_float(0, 4)); $this->assertEquals(quiz_format_question_grade($quiz, 1.000000000000), format_float(1, 4)); } /** * Test deleting a quiz instance. */ public function test_quiz_delete_instance(): void { global $SITE, $DB; $this->resetAfterTest(true); $this->setAdminUser(); // Setup a quiz with 1 standard and 1 random question. $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz'); $quiz = $quizgenerator->create_instance(['course' => $SITE->id, 'questionsperpage' => 3, 'grade' => 100.0]); $context = context_module::instance($quiz->cmid); $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); $cat = $questiongenerator->create_question_category(); $standardq = $questiongenerator->create_question('shortanswer', null, ['category' => $cat->id]); quiz_add_quiz_question($standardq->id, $quiz); $this->add_random_questions($quiz->id, 0, $cat->id, 1); // Get the random question. $randomq = $DB->get_record('question', ['qtype' => 'random']); quiz_delete_instance($quiz->id); // Check that the random question was deleted. if ($randomq) { $this->assertEquals(0, $DB->count_records('question', ['id' => $randomq->id])); } // Check that the standard question was not deleted. $this->assertEquals(1, $DB->count_records('question', ['id' => $standardq->id])); // Check that all the slots were removed. $this->assertEquals(0, $DB->count_records('quiz_slots', ['quizid' => $quiz->id])); // Check that the quiz was removed. $this->assertEquals(0, $DB->count_records('quiz', ['id' => $quiz->id])); // Check that any question references linked to this quiz are gone. $this->assertEquals(0, $DB->count_records('question_references', ['usingcontextid' => $context->id])); $this->assertEquals(0, $DB->count_records('question_set_references', ['usingcontextid' => $context->id])); } /** * Test deleting a quiz when the course is deleted. * * @covers ::quiz_delete_instance */ public function test_quiz_when_delete_course(): void { global $DB, $USER; $this->resetAfterTest(); $this->setAdminUser(); // Step 1: Create one course and a user with editing teacher capabilities. $generator = $this->getDataGenerator(); $course = $generator->create_course(); $teacher = $USER; $generator->enrol_user($teacher->id, $course->id, 'editingteacher'); // Create a quiz with questions in the first course. $quiz = $this->create_test_quiz($course); $context = \context_module::instance($quiz->cmid); // Create questions. $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); $cat = $questiongenerator->create_question_category(['contextid' => $context->id]); $saq = $questiongenerator->create_question('shortanswer', null, ['category' => $cat->id]); // Add to the quiz. quiz_add_quiz_question($saq->id, $quiz); // Delete the course. delete_course($course, false); // Check that the question was deleted. $this->assertFalse($DB->record_exists('question', ['id' => $saq->id])); // Check that all the slots were removed. $this->assertFalse($DB->record_exists('quiz_slots', ['quizid' => $quiz->id])); // Check that the quiz was removed. $this->assertFalse($DB->record_exists('quiz', ['id' => $quiz->id])); // Check that any question references linked to this quiz are gone. $this->assertFalse($DB->record_exists('question_references', ['usingcontextid' => $context->id])); $this->assertFalse($DB->record_exists('question_set_references', ['usingcontextid' => $context->id])); } public function test_quiz_get_user_attempts(): void { global $DB; $this->resetAfterTest(); $dg = $this->getDataGenerator(); $quizgen = $dg->get_plugin_generator('mod_quiz'); $course = $dg->create_course(); $u1 = $dg->create_user(); $u2 = $dg->create_user(); $u3 = $dg->create_user(); $u4 = $dg->create_user(); $role = $DB->get_record('role', ['shortname' => 'student']); $dg->enrol_user($u1->id, $course->id, $role->id); $dg->enrol_user($u2->id, $course->id, $role->id); $dg->enrol_user($u3->id, $course->id, $role->id); $dg->enrol_user($u4->id, $course->id, $role->id); $quiz1 = $quizgen->create_instance(['course' => $course->id, 'sumgrades' => 2]); $quiz2 = $quizgen->create_instance(['course' => $course->id, 'sumgrades' => 2]); // Questions. $questgen = $dg->get_plugin_generator('core_question'); $quizcat = $questgen->create_question_category(); $question = $questgen->create_question('numerical', null, ['category' => $quizcat->id]); quiz_add_quiz_question($question->id, $quiz1); quiz_add_quiz_question($question->id, $quiz2); $quizobj1a = quiz_settings::create($quiz1->id, $u1->id); $quizobj1b = quiz_settings::create($quiz1->id, $u2->id); $quizobj1c = quiz_settings::create($quiz1->id, $u3->id); $quizobj1d = quiz_settings::create($quiz1->id, $u4->id); $quizobj2a = quiz_settings::create($quiz2->id, $u1->id); // Set attempts. $quba1a = \question_engine::make_questions_usage_by_activity('mod_quiz', $quizobj1a->get_context()); $quba1a->set_preferred_behaviour($quizobj1a->get_quiz()->preferredbehaviour); $quba1b = \question_engine::make_questions_usage_by_activity('mod_quiz', $quizobj1b->get_context()); $quba1b->set_preferred_behaviour($quizobj1b->get_quiz()->preferredbehaviour); $quba1c = \question_engine::make_questions_usage_by_activity('mod_quiz', $quizobj1c->get_context()); $quba1c->set_preferred_behaviour($quizobj1c->get_quiz()->preferredbehaviour); $quba1d = \question_engine::make_questions_usage_by_activity('mod_quiz', $quizobj1d->get_context()); $quba1d->set_preferred_behaviour($quizobj1d->get_quiz()->preferredbehaviour); $quba2a = \question_engine::make_questions_usage_by_activity('mod_quiz', $quizobj2a->get_context()); $quba2a->set_preferred_behaviour($quizobj2a->get_quiz()->preferredbehaviour); $timenow = time(); // User 1 passes quiz 1. $attempt = quiz_create_attempt($quizobj1a, 1, false, $timenow, false, $u1->id); quiz_start_new_attempt($quizobj1a, $quba1a, $attempt, 1, $timenow); quiz_attempt_save_started($quizobj1a, $quba1a, $attempt); $attemptobj = quiz_attempt::create($attempt->id); $attemptobj->process_submitted_actions($timenow, false, [1 => ['answer' => '3.14']]); $attemptobj->process_submit($timenow, false); $attemptobj->process_grade_submission($timenow); // User 2 goes overdue in quiz 1. $attempt = quiz_create_attempt($quizobj1b, 1, false, $timenow, false, $u2->id); quiz_start_new_attempt($quizobj1b, $quba1b, $attempt, 1, $timenow); quiz_attempt_save_started($quizobj1b, $quba1b, $attempt); $attemptobj = quiz_attempt::create($attempt->id); $attemptobj->process_going_overdue($timenow, true); // User 3 does not finish quiz 1. $attempt = quiz_create_attempt($quizobj1c, 1, false, $timenow, false, $u3->id); quiz_start_new_attempt($quizobj1c, $quba1c, $attempt, 1, $timenow); quiz_attempt_save_started($quizobj1c, $quba1c, $attempt); // User 4 abandons the quiz 1. $attempt = quiz_create_attempt($quizobj1d, 1, false, $timenow, false, $u4->id); quiz_start_new_attempt($quizobj1d, $quba1d, $attempt, 1, $timenow); quiz_attempt_save_started($quizobj1d, $quba1d, $attempt); $attemptobj = quiz_attempt::create($attempt->id); $attemptobj->process_abandon($timenow, true); // User 1 attempts the quiz three times (abandon, finish, in progress). $quba2a = \question_engine::make_questions_usage_by_activity('mod_quiz', $quizobj2a->get_context()); $quba2a->set_preferred_behaviour($quizobj2a->get_quiz()->preferredbehaviour); $attempt = quiz_create_attempt($quizobj2a, 1, false, $timenow, false, $u1->id); quiz_start_new_attempt($quizobj2a, $quba2a, $attempt, 1, $timenow); quiz_attempt_save_started($quizobj2a, $quba2a, $attempt); $attemptobj = quiz_attempt::create($attempt->id); $attemptobj->process_abandon($timenow, true); $quba2a = \question_engine::make_questions_usage_by_activity('mod_quiz', $quizobj2a->get_context()); $quba2a->set_preferred_behaviour($quizobj2a->get_quiz()->preferredbehaviour); $attempt = quiz_create_attempt($quizobj2a, 2, false, $timenow, false, $u1->id); quiz_start_new_attempt($quizobj2a, $quba2a, $attempt, 2, $timenow); quiz_attempt_save_started($quizobj2a, $quba2a, $attempt); $attemptobj = quiz_attempt::create($attempt->id); $attemptobj->process_submit($timenow, false); $attemptobj->process_grade_submission($timenow); $quba2a = \question_engine::make_questions_usage_by_activity('mod_quiz', $quizobj2a->get_context()); $quba2a->set_preferred_behaviour($quizobj2a->get_quiz()->preferredbehaviour); $attempt = quiz_create_attempt($quizobj2a, 3, false, $timenow, false, $u1->id); quiz_start_new_attempt($quizobj2a, $quba2a, $attempt, 3, $timenow); quiz_attempt_save_started($quizobj2a, $quba2a, $attempt); // Check for user 1. $attempts = quiz_get_user_attempts($quiz1->id, $u1->id, 'all'); $this->assertCount(1, $attempts); $attempt = array_shift($attempts); $this->assertEquals(quiz_attempt::FINISHED, $attempt->state); $this->assertEquals($u1->id, $attempt->userid); $this->assertEquals($quiz1->id, $attempt->quiz); $attempts = quiz_get_user_attempts($quiz1->id, $u1->id, 'finished'); $this->assertCount(1, $attempts); $attempt = array_shift($attempts); $this->assertEquals(quiz_attempt::FINISHED, $attempt->state); $this->assertEquals($u1->id, $attempt->userid); $this->assertEquals($quiz1->id, $attempt->quiz); $attempts = quiz_get_user_attempts($quiz1->id, $u1->id, 'unfinished'); $this->assertCount(0, $attempts); // Check for user 2. $attempts = quiz_get_user_attempts($quiz1->id, $u2->id, 'all'); $this->assertCount(1, $attempts); $attempt = array_shift($attempts); $this->assertEquals(quiz_attempt::OVERDUE, $attempt->state); $this->assertEquals($u2->id, $attempt->userid); $this->assertEquals($quiz1->id, $attempt->quiz); $attempts = quiz_get_user_attempts($quiz1->id, $u2->id, 'finished'); $this->assertCount(0, $attempts); $attempts = quiz_get_user_attempts($quiz1->id, $u2->id, 'unfinished'); $this->assertCount(1, $attempts); $attempt = array_shift($attempts); $this->assertEquals(quiz_attempt::OVERDUE, $attempt->state); $this->assertEquals($u2->id, $attempt->userid); $this->assertEquals($quiz1->id, $attempt->quiz); // Check for user 3. $attempts = quiz_get_user_attempts($quiz1->id, $u3->id, 'all'); $this->assertCount(1, $attempts); $attempt = array_shift($attempts); $this->assertEquals(quiz_attempt::IN_PROGRESS, $attempt->state); $this->assertEquals($u3->id, $attempt->userid); $this->assertEquals($quiz1->id, $attempt->quiz); $attempts = quiz_get_user_attempts($quiz1->id, $u3->id, 'finished'); $this->assertCount(0, $attempts); $attempts = quiz_get_user_attempts($quiz1->id, $u3->id, 'unfinished'); $this->assertCount(1, $attempts); $attempt = array_shift($attempts); $this->assertEquals(quiz_attempt::IN_PROGRESS, $attempt->state); $this->assertEquals($u3->id, $attempt->userid); $this->assertEquals($quiz1->id, $attempt->quiz); // Check for user 4. $attempts = quiz_get_user_attempts($quiz1->id, $u4->id, 'all'); $this->assertCount(1, $attempts); $attempt = array_shift($attempts); $this->assertEquals(quiz_attempt::ABANDONED, $attempt->state); $this->assertEquals($u4->id, $attempt->userid); $this->assertEquals($quiz1->id, $attempt->quiz); $attempts = quiz_get_user_attempts($quiz1->id, $u4->id, 'finished'); $this->assertCount(1, $attempts); $attempt = array_shift($attempts); $this->assertEquals(quiz_attempt::ABANDONED, $attempt->state); $this->assertEquals($u4->id, $attempt->userid); $this->assertEquals($quiz1->id, $attempt->quiz); $attempts = quiz_get_user_attempts($quiz1->id, $u4->id, 'unfinished'); $this->assertCount(0, $attempts); // Multiple attempts for user 1 in quiz 2. $attempts = quiz_get_user_attempts($quiz2->id, $u1->id, 'all'); $this->assertCount(3, $attempts); $attempt = array_shift($attempts); $this->assertEquals(quiz_attempt::ABANDONED, $attempt->state); $this->assertEquals($u1->id, $attempt->userid); $this->assertEquals($quiz2->id, $attempt->quiz); $attempt = array_shift($attempts); $this->assertEquals(quiz_attempt::FINISHED, $attempt->state); $this->assertEquals($u1->id, $attempt->userid); $this->assertEquals($quiz2->id, $attempt->quiz); $attempt = array_shift($attempts); $this->assertEquals(quiz_attempt::IN_PROGRESS, $attempt->state); $this->assertEquals($u1->id, $attempt->userid); $this->assertEquals($quiz2->id, $attempt->quiz); $attempts = quiz_get_user_attempts($quiz2->id, $u1->id, 'finished'); $this->assertCount(2, $attempts); $attempt = array_shift($attempts); $this->assertEquals(quiz_attempt::ABANDONED, $attempt->state); $attempt = array_shift($attempts); $this->assertEquals(quiz_attempt::FINISHED, $attempt->state); $attempts = quiz_get_user_attempts($quiz2->id, $u1->id, 'unfinished'); $this->assertCount(1, $attempts); $attempt = array_shift($attempts); // Multiple quiz attempts fetched at once. $attempts = quiz_get_user_attempts([$quiz1->id, $quiz2->id], $u1->id, 'all'); $this->assertCount(4, $attempts); $attempt = array_shift($attempts); $this->assertEquals(quiz_attempt::FINISHED, $attempt->state); $this->assertEquals($u1->id, $attempt->userid); $this->assertEquals($quiz1->id, $attempt->quiz); $attempt = array_shift($attempts); $this->assertEquals(quiz_attempt::ABANDONED, $attempt->state); $this->assertEquals($u1->id, $attempt->userid); $this->assertEquals($quiz2->id, $attempt->quiz); $attempt = array_shift($attempts); $this->assertEquals(quiz_attempt::FINISHED, $attempt->state); $this->assertEquals($u1->id, $attempt->userid); $this->assertEquals($quiz2->id, $attempt->quiz); $attempt = array_shift($attempts); $this->assertEquals(quiz_attempt::IN_PROGRESS, $attempt->state); $this->assertEquals($u1->id, $attempt->userid); $this->assertEquals($quiz2->id, $attempt->quiz); } /** * Test for quiz_get_group_override_priorities(). */ public function test_quiz_get_group_override_priorities(): void { global $DB; $this->resetAfterTest(); $dg = $this->getDataGenerator(); $quizgen = $dg->get_plugin_generator('mod_quiz'); $course = $dg->create_course(); $quiz = $quizgen->create_instance(['course' => $course->id, 'sumgrades' => 2]); $this->assertNull(quiz_get_group_override_priorities($quiz->id)); $group1 = $this->getDataGenerator()->create_group(['courseid' => $course->id]); $group2 = $this->getDataGenerator()->create_group(['courseid' => $course->id]); $now = 100; $override1 = (object)[ 'quiz' => $quiz->id, 'groupid' => $group1->id, 'timeopen' => $now, 'timeclose' => $now + 20 ]; $DB->insert_record('quiz_overrides', $override1); $override2 = (object)[ 'quiz' => $quiz->id, 'groupid' => $group2->id, 'timeopen' => $now - 10, 'timeclose' => $now + 10 ]; $DB->insert_record('quiz_overrides', $override2); $priorities = quiz_get_group_override_priorities($quiz->id); $this->assertNotEmpty($priorities); $openpriorities = $priorities['open']; // Override 2's time open has higher priority since it is sooner than override 1's. $this->assertEquals(2, $openpriorities[$override1->timeopen]); $this->assertEquals(1, $openpriorities[$override2->timeopen]); $closepriorities = $priorities['close']; // Override 1's time close has higher priority since it is later than override 2's. $this->assertEquals(1, $closepriorities[$override1->timeclose]); $this->assertEquals(2, $closepriorities[$override2->timeclose]); } public function test_quiz_core_calendar_provide_event_action_open(): void { $this->resetAfterTest(); $this->setAdminUser(); // Create a course. $course = $this->getDataGenerator()->create_course(); // Create a student and enrol into the course. $student = $this->getDataGenerator()->create_and_enrol($course, 'student'); // Create a quiz. $quiz = $this->getDataGenerator()->create_module('quiz', ['course' => $course->id, 'timeopen' => time() - DAYSECS, 'timeclose' => time() + DAYSECS]); // Create a calendar event. $event = $this->create_action_event($course->id, $quiz->id, QUIZ_EVENT_TYPE_OPEN); // Now, log in as student. $this->setUser($student); // Create an action factory. $factory = new \core_calendar\action_factory(); // Decorate action event. $actionevent = mod_quiz_core_calendar_provide_event_action($event, $factory); // Confirm the event was decorated. $this->assertInstanceOf('\core_calendar\local\event\value_objects\action', $actionevent); $this->assertEquals(get_string('attemptquiznow', 'quiz'), $actionevent->get_name()); $this->assertInstanceOf('moodle_url', $actionevent->get_url()); $this->assertEquals(1, $actionevent->get_item_count()); $this->assertTrue($actionevent->is_actionable()); } public function test_quiz_core_calendar_provide_event_action_open_for_user(): void { $this->resetAfterTest(); $this->setAdminUser(); // Create a course. $course = $this->getDataGenerator()->create_course(); // Create a student and enrol into the course. $student = $this->getDataGenerator()->create_and_enrol($course, 'student'); // Create a quiz. $quiz = $this->getDataGenerator()->create_module('quiz', ['course' => $course->id, 'timeopen' => time() - DAYSECS, 'timeclose' => time() + DAYSECS]); // Create a calendar event. $event = $this->create_action_event($course->id, $quiz->id, QUIZ_EVENT_TYPE_OPEN); // Create an action factory. $factory = new \core_calendar\action_factory(); // Decorate action event for the student. $actionevent = mod_quiz_core_calendar_provide_event_action($event, $factory, $student->id); // Confirm the event was decorated. $this->assertInstanceOf('\core_calendar\local\event\value_objects\action', $actionevent); $this->assertEquals(get_string('attemptquiznow', 'quiz'), $actionevent->get_name()); $this->assertInstanceOf('moodle_url', $actionevent->get_url()); $this->assertEquals(1, $actionevent->get_item_count()); $this->assertTrue($actionevent->is_actionable()); } public function test_quiz_core_calendar_provide_event_action_closed(): void { $this->resetAfterTest(); $this->setAdminUser(); // Create a course. $course = $this->getDataGenerator()->create_course(); // Create a quiz. $quiz = $this->getDataGenerator()->create_module('quiz', ['course' => $course->id, 'timeclose' => time() - DAYSECS]); // Create a calendar event. $event = $this->create_action_event($course->id, $quiz->id, QUIZ_EVENT_TYPE_CLOSE); // Create an action factory. $factory = new \core_calendar\action_factory(); // Confirm the result was null. $this->assertNull(mod_quiz_core_calendar_provide_event_action($event, $factory)); } public function test_quiz_core_calendar_provide_event_action_closed_for_user(): void { $this->resetAfterTest(); $this->setAdminUser(); // Create a course. $course = $this->getDataGenerator()->create_course(); // Create a student. $student = $this->getDataGenerator()->create_and_enrol($course, 'student'); // Create a quiz. $quiz = $this->getDataGenerator()->create_module('quiz', ['course' => $course->id, 'timeclose' => time() - DAYSECS]); // Create a calendar event. $event = $this->create_action_event($course->id, $quiz->id, QUIZ_EVENT_TYPE_CLOSE); // Create an action factory. $factory = new \core_calendar\action_factory(); // Confirm the result was null. $this->assertNull(mod_quiz_core_calendar_provide_event_action($event, $factory, $student->id)); } public function test_quiz_core_calendar_provide_event_action_open_in_future(): void { $this->resetAfterTest(); $this->setAdminUser(); // Create a course. $course = $this->getDataGenerator()->create_course(); // Create a student and enrol into the course. $student = $this->getDataGenerator()->create_and_enrol($course, 'student'); // Create a quiz. $quiz = $this->getDataGenerator()->create_module('quiz', ['course' => $course->id, 'timeopen' => time() + DAYSECS]); // Create a calendar event. $event = $this->create_action_event($course->id, $quiz->id, QUIZ_EVENT_TYPE_CLOSE); // Now, log in as student. $this->setUser($student); // Create an action factory. $factory = new \core_calendar\action_factory(); // Decorate action event. $actionevent = mod_quiz_core_calendar_provide_event_action($event, $factory); // Confirm the event was decorated. $this->assertInstanceOf('\core_calendar\local\event\value_objects\action', $actionevent); $this->assertEquals(get_string('attemptquiznow', 'quiz'), $actionevent->get_name()); $this->assertInstanceOf('moodle_url', $actionevent->get_url()); $this->assertEquals(1, $actionevent->get_item_count()); $this->assertFalse($actionevent->is_actionable()); } public function test_quiz_core_calendar_provide_event_action_open_in_future_for_user(): void { $this->resetAfterTest(); $this->setAdminUser(); // Create a course. $course = $this->getDataGenerator()->create_course(); // Create a student and enrol into the course. $student = $this->getDataGenerator()->create_and_enrol($course, 'student'); // Create a quiz. $quiz = $this->getDataGenerator()->create_module('quiz', ['course' => $course->id, 'timeopen' => time() + DAYSECS]); // Create a calendar event. $event = $this->create_action_event($course->id, $quiz->id, QUIZ_EVENT_TYPE_CLOSE); // Create an action factory. $factory = new \core_calendar\action_factory(); // Decorate action event for the student. $actionevent = mod_quiz_core_calendar_provide_event_action($event, $factory, $student->id); // Confirm the event was decorated. $this->assertInstanceOf('\core_calendar\local\event\value_objects\action', $actionevent); $this->assertEquals(get_string('attemptquiznow', 'quiz'), $actionevent->get_name()); $this->assertInstanceOf('moodle_url', $actionevent->get_url()); $this->assertEquals(1, $actionevent->get_item_count()); $this->assertFalse($actionevent->is_actionable()); } public function test_quiz_core_calendar_provide_event_action_no_capability(): void { global $DB; $this->resetAfterTest(); $this->setAdminUser(); // Create a course. $course = $this->getDataGenerator()->create_course(); // Create a student. $student = $this->getDataGenerator()->create_user(); $studentrole = $DB->get_record('role', ['shortname' => 'student']); // Enrol student. $this->assertTrue($this->getDataGenerator()->enrol_user($student->id, $course->id, $studentrole->id)); // Create a quiz. $quiz = $this->getDataGenerator()->create_module('quiz', ['course' => $course->id]); // Remove the permission to attempt or review the quiz for the student role. $coursecontext = \context_course::instance($course->id); assign_capability('mod/quiz:reviewmyattempts', CAP_PROHIBIT, $studentrole->id, $coursecontext); assign_capability('mod/quiz:attempt', CAP_PROHIBIT, $studentrole->id, $coursecontext); // Create a calendar event. $event = $this->create_action_event($course->id, $quiz->id, QUIZ_EVENT_TYPE_OPEN); // Create an action factory. $factory = new \core_calendar\action_factory(); // Set current user to the student. $this->setUser($student); // Confirm null is returned. $this->assertNull(mod_quiz_core_calendar_provide_event_action($event, $factory)); } public function test_quiz_core_calendar_provide_event_action_no_capability_for_user(): void { global $DB; $this->resetAfterTest(); $this->setAdminUser(); // Create a course. $course = $this->getDataGenerator()->create_course(); // Create a student. $student = $this->getDataGenerator()->create_user(); $studentrole = $DB->get_record('role', ['shortname' => 'student']); // Enrol student. $this->assertTrue($this->getDataGenerator()->enrol_user($student->id, $course->id, $studentrole->id)); // Create a quiz. $quiz = $this->getDataGenerator()->create_module('quiz', ['course' => $course->id]); // Remove the permission to attempt or review the quiz for the student role. $coursecontext = \context_course::instance($course->id); assign_capability('mod/quiz:reviewmyattempts', CAP_PROHIBIT, $studentrole->id, $coursecontext); assign_capability('mod/quiz:attempt', CAP_PROHIBIT, $studentrole->id, $coursecontext); // Create a calendar event. $event = $this->create_action_event($course->id, $quiz->id, QUIZ_EVENT_TYPE_OPEN); // Create an action factory. $factory = new \core_calendar\action_factory(); // Confirm null is returned. $this->assertNull(mod_quiz_core_calendar_provide_event_action($event, $factory, $student->id)); } public function test_quiz_core_calendar_provide_event_action_already_finished(): void { global $DB; $this->resetAfterTest(); $this->setAdminUser(); // Create a course. $course = $this->getDataGenerator()->create_course(); // Create a student. $student = $this->getDataGenerator()->create_user(); $studentrole = $DB->get_record('role', ['shortname' => 'student']); // Enrol student. $this->assertTrue($this->getDataGenerator()->enrol_user($student->id, $course->id, $studentrole->id)); // Create a quiz. $quiz = $this->getDataGenerator()->create_module('quiz', ['course' => $course->id, 'sumgrades' => 1]); // Add a question to the quiz. $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); $cat = $questiongenerator->create_question_category(); $question = $questiongenerator->create_question('numerical', null, ['category' => $cat->id]); quiz_add_quiz_question($question->id, $quiz); // Get the quiz object. $quizobj = quiz_settings::create($quiz->id, $student->id); // Create an attempt for the student in the quiz. $timenow = time(); $attempt = quiz_create_attempt($quizobj, 1, false, $timenow, false, $student->id); $quba = \question_engine::make_questions_usage_by_activity('mod_quiz', $quizobj->get_context()); $quba->set_preferred_behaviour($quizobj->get_quiz()->preferredbehaviour); quiz_start_new_attempt($quizobj, $quba, $attempt, 1, $timenow); quiz_attempt_save_started($quizobj, $quba, $attempt); // Finish the attempt. $attemptobj = quiz_attempt::create($attempt->id); $attemptobj->process_submit($timenow, false); $attemptobj->process_grade_submission($timenow); // Create a calendar event. $event = $this->create_action_event($course->id, $quiz->id, QUIZ_EVENT_TYPE_OPEN); // Create an action factory. $factory = new \core_calendar\action_factory(); // Set current user to the student. $this->setUser($student); // Confirm null is returned. $this->assertNull(mod_quiz_core_calendar_provide_event_action($event, $factory)); } public function test_quiz_core_calendar_provide_event_action_already_finished_for_user(): void { global $DB; $this->resetAfterTest(); $this->setAdminUser(); // Create a course. $course = $this->getDataGenerator()->create_course(); // Create a student. $student = $this->getDataGenerator()->create_user(); $studentrole = $DB->get_record('role', ['shortname' => 'student']); // Enrol student. $this->assertTrue($this->getDataGenerator()->enrol_user($student->id, $course->id, $studentrole->id)); // Create a quiz. $quiz = $this->getDataGenerator()->create_module('quiz', ['course' => $course->id, 'sumgrades' => 1]); // Add a question to the quiz. $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); $cat = $questiongenerator->create_question_category(); $question = $questiongenerator->create_question('numerical', null, ['category' => $cat->id]); quiz_add_quiz_question($question->id, $quiz); // Get the quiz object. $quizobj = quiz_settings::create($quiz->id, $student->id); // Create an attempt for the student in the quiz. $timenow = time(); $attempt = quiz_create_attempt($quizobj, 1, false, $timenow, false, $student->id); $quba = \question_engine::make_questions_usage_by_activity('mod_quiz', $quizobj->get_context()); $quba->set_preferred_behaviour($quizobj->get_quiz()->preferredbehaviour); quiz_start_new_attempt($quizobj, $quba, $attempt, 1, $timenow); quiz_attempt_save_started($quizobj, $quba, $attempt); // Finish the attempt. $attemptobj = quiz_attempt::create($attempt->id); $attemptobj->process_submit($timenow, false); $attemptobj->process_grade_submission($timenow); // Create a calendar event. $event = $this->create_action_event($course->id, $quiz->id, QUIZ_EVENT_TYPE_OPEN); // Create an action factory. $factory = new \core_calendar\action_factory(); // Confirm null is returned. $this->assertNull(mod_quiz_core_calendar_provide_event_action($event, $factory, $student->id)); } public function test_quiz_core_calendar_provide_event_action_already_completed(): void { $this->resetAfterTest(); set_config('enablecompletion', 1); $this->setAdminUser(); // Create the activity. $course = $this->getDataGenerator()->create_course(['enablecompletion' => 1]); $quiz = $this->getDataGenerator()->create_module('quiz', ['course' => $course->id], ['completion' => 2, 'completionview' => 1, 'completionexpected' => time() + DAYSECS]); // Get some additional data. $cm = get_coursemodule_from_instance('quiz', $quiz->id); // Create a calendar event. $event = $this->create_action_event($course->id, $quiz->id, \core_completion\api::COMPLETION_EVENT_TYPE_DATE_COMPLETION_EXPECTED); // Mark the activity as completed. $completion = new \completion_info($course); $completion->set_module_viewed($cm); // Create an action factory. $factory = new \core_calendar\action_factory(); // Decorate action event. $actionevent = mod_quiz_core_calendar_provide_event_action($event, $factory); // Ensure result was null. $this->assertNull($actionevent); } public function test_quiz_core_calendar_provide_event_action_already_completed_for_user(): void { $this->resetAfterTest(); set_config('enablecompletion', 1); $this->setAdminUser(); // Create the activity. $course = $this->getDataGenerator()->create_course(['enablecompletion' => 1]); $quiz = $this->getDataGenerator()->create_module('quiz', ['course' => $course->id], ['completion' => 2, 'completionview' => 1, 'completionexpected' => time() + DAYSECS]); // Enrol a student in the course. $student = $this->getDataGenerator()->create_and_enrol($course, 'student'); // Get some additional data. $cm = get_coursemodule_from_instance('quiz', $quiz->id); // Create a calendar event. $event = $this->create_action_event($course->id, $quiz->id, \core_completion\api::COMPLETION_EVENT_TYPE_DATE_COMPLETION_EXPECTED); // Mark the activity as completed for the student. $completion = new \completion_info($course); $completion->set_module_viewed($cm, $student->id); // Create an action factory. $factory = new \core_calendar\action_factory(); // Decorate action event for the student. $actionevent = mod_quiz_core_calendar_provide_event_action($event, $factory, $student->id); // Ensure result was null. $this->assertNull($actionevent); } /** * Creates an action event. * * @param int $courseid * @param int $instanceid The quiz id. * @param string $eventtype The event type. eg. QUIZ_EVENT_TYPE_OPEN. * @return bool|calendar_event */ private function create_action_event($courseid, $instanceid, $eventtype) { $event = new \stdClass(); $event->name = 'Calendar event'; $event->modulename = 'quiz'; $event->courseid = $courseid; $event->instance = $instanceid; $event->type = CALENDAR_EVENT_TYPE_ACTION; $event->eventtype = $eventtype; $event->timestart = time(); return \calendar_event::create($event); } /** * Test the callback responsible for returning the completion rule descriptions. * This function should work given either an instance of the module (cm_info), such as when checking the active rules, * or if passed a stdClass of similar structure, such as when checking the the default completion settings for a mod type. */ public function test_mod_quiz_completion_get_active_rule_descriptions(): void { $this->resetAfterTest(); $this->setAdminUser(); // Two activities, both with automatic completion. One has the 'completionsubmit' rule, one doesn't. $course = $this->getDataGenerator()->create_course(['enablecompletion' => 2]); $quiz1 = $this->getDataGenerator()->create_module('quiz', [ 'course' => $course->id, 'completion' => 2, 'completionusegrade' => 1, 'completionpassgrade' => 1, 'completionattemptsexhausted' => 1, ]); $quiz2 = $this->getDataGenerator()->create_module('quiz', [ 'course' => $course->id, 'completion' => 2, 'completionusegrade' => 0 ]); $cm1 = \cm_info::create(get_coursemodule_from_instance('quiz', $quiz1->id)); $cm2 = \cm_info::create(get_coursemodule_from_instance('quiz', $quiz2->id)); // Data for the stdClass input type. // This type of input would occur when checking the default completion rules for an activity type, where we don't have // any access to cm_info, rather the input is a stdClass containing completion and customdata attributes, just like cm_info. $moddefaults = new \stdClass(); $moddefaults->customdata = ['customcompletionrules' => [ 'completionattemptsexhausted' => 1, ]]; $moddefaults->completion = 2; $activeruledescriptions = [ get_string('completionpassorattemptsexhausteddesc', 'quiz'), ]; $this->assertEquals(mod_quiz_get_completion_active_rule_descriptions($cm1), $activeruledescriptions); $this->assertEquals(mod_quiz_get_completion_active_rule_descriptions($cm2), []); $this->assertEquals(mod_quiz_get_completion_active_rule_descriptions($moddefaults), $activeruledescriptions); $this->assertEquals(mod_quiz_get_completion_active_rule_descriptions(new \stdClass()), []); } /** * A user who does not have capabilities to add events to the calendar should be able to create a quiz. */ public function test_creation_with_no_calendar_capabilities(): void { $this->resetAfterTest(); $course = self::getDataGenerator()->create_course(); $context = \context_course::instance($course->id); $user = self::getDataGenerator()->create_and_enrol($course, 'editingteacher'); $roleid = self::getDataGenerator()->create_role(); self::getDataGenerator()->role_assign($roleid, $user->id, $context->id); assign_capability('moodle/calendar:manageentries', CAP_PROHIBIT, $roleid, $context, true); $generator = self::getDataGenerator()->get_plugin_generator('mod_quiz'); // Create an instance as a user without the calendar capabilities. $this->setUser($user); $time = time(); $params = [ 'course' => $course->id, 'timeopen' => $time + 200, 'timeclose' => $time + 2000, ]; $generator->create_instance($params); } /** * Data provider for summarise_response() test cases. * * @return array List of data sets (test cases) */ public static function mod_quiz_inplace_editable_provider(): array { return [ 'set to A1' => [1, 'A1'], 'set with HTML characters' => [2, 'A & & <-:'], 'set to integer' => [3, '3'], 'set to blank' => [4, ''], 'set with Unicode characters' => [1, 'L\'Aina Lluís^'], 'set with Unicode at the truncation point' => [1, '123456789012345碁'], 'set with HTML Char at the truncation point' => [1, '123456789012345>'], ]; } /** * Test customised and automated question numbering for a given slot number and customised value. * * @dataProvider mod_quiz_inplace_editable_provider * @param int $slotnumber * @param string $newvalue * @covers ::mod_quiz_inplace_editable */ public function test_mod_quiz_inplace_editable(int $slotnumber, string $newvalue): void { global $CFG; require_once($CFG->dirroot . '/lib/external/externallib.php'); $this->resetAfterTest(); $this->setAdminUser(); $course = self::getDataGenerator()->create_course(); $quiz = $this->getDataGenerator()->create_module('quiz', ['course' => $course->id, 'sumgrades' => 1]); $cm = get_coursemodule_from_id('quiz', $quiz->cmid); // Add few questions to the quiz. $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); $cat = $questiongenerator->create_question_category(); $question = $questiongenerator->create_question('truefalse', null, ['category' => $cat->id]); quiz_add_quiz_question($question->id, $quiz); $question = $questiongenerator->create_question('shortanswer', null, ['category' => $cat->id]); quiz_add_quiz_question($question->id, $quiz); $question = $questiongenerator->create_question('multichoice', null, ['category' => $cat->id]); quiz_add_quiz_question($question->id, $quiz); $question = $questiongenerator->create_question('numerical', null, ['category' => $cat->id]); quiz_add_quiz_question($question->id, $quiz); // Create the quiz object. $quizobj = new quiz_settings($quiz, $cm, $course); $structure = $quizobj->get_structure(); $slots = $structure->get_slots(); $this->assertEquals(4, count($slots)); $slotid = $structure->get_slot_id_for_slot($slotnumber); $inplaceeditable = mod_quiz_inplace_editable('slotdisplaynumber', $slotid, $newvalue); $result = \core_external::update_inplace_editable('mod_quiz', 'slotdisplaynumber', $slotid, $newvalue); $result = external_api::clean_returnvalue(\core_external::update_inplace_editable_returns(), $result); $this->assertEquals(count((array) $inplaceeditable), count($result)); $this->assertEquals($slotid, $result['itemid']); if ($newvalue === '' || is_null($newvalue)) { // Check against default. $this->assertEquals($slotnumber, $result['displayvalue']); $this->assertEquals($slotnumber, $result['value']); } else { // Check against the custom number. $this->assertEquals(s($newvalue), $result['displayvalue']); $this->assertEquals($newvalue, $result['value']); } } } PK9B\e_1_1;tests/quiz_notify_attempt_manual_grading_completed_test.phpnu[. /** * Contains the class containing unit tests for the quiz notify attempt manual grading completed cron task. * * @package mod_quiz * @copyright 2021 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace mod_quiz; use advanced_testcase; use context_module; use core\context; use mod_quiz\task\quiz_notify_attempt_manual_grading_completed; use question_engine; use question_usage_by_activity; use stdClass; /** * Class containing unit tests for the quiz notify attempt manual grading completed cron task. * * @package mod_quiz * @copyright 2021 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class quiz_notify_attempt_manual_grading_completed_test extends advanced_testcase { /** @var stdClass $course Test course to contain quiz. */ protected stdClass $course; /** @var stdClass $quiz A test quiz. */ protected stdClass $quiz; /** @var context The quiz context. */ protected context $context; /** @var stdClass The course_module. */ protected stdClass $cm; /** @var stdClass The student test. */ protected stdClass $student; /** @var stdClass The student role. */ protected stdClass $studentrole; /** @var quiz_settings Object containing the quiz settings. */ protected quiz_settings $quizobj; /** @var question_usage_by_activity The question usage for this quiz attempt. */ protected question_usage_by_activity $quba; /** * Standard test setup. * * Create a course with a quiz and a student. * The quiz has a truefalse question and an essay question. * * Also create some bits of a quiz attempt to be used later. */ public function setUp(): void { global $DB; parent::setUp(); $this->resetAfterTest(); $this->setAdminUser(); // Setup test data. $this->course = $this->getDataGenerator()->create_course(); // Create a user enrolled as a student. $this->student = self::getDataGenerator()->create_user(); $this->studentrole = $DB->get_record('role', ['shortname' => 'student']); $this->getDataGenerator()->enrol_user($this->student->id, $this->course->id, $this->studentrole->id); // Make a quiz. $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz'); $this->quiz = $quizgenerator->create_instance(['course' => $this->course->id, 'questionsperpage' => 0, 'grade' => 100.0, 'sumgrades' => 2]); $this->context = context_module::instance($this->quiz->cmid); $this->cm = get_coursemodule_from_instance('quiz', $this->quiz->id); // Create a truefalse question and an essay question. /** @var \core_question_generator $questiongenerator */ $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); $cat = $questiongenerator->create_question_category(); $truefalse = $questiongenerator->create_question('truefalse', null, ['category' => $cat->id]); $essay = $questiongenerator->create_question('essay', null, ['category' => $cat->id]); // Add them to the quiz. quiz_add_quiz_question($truefalse->id, $this->quiz); quiz_add_quiz_question($essay->id, $this->quiz); $this->quizobj = quiz_settings::create($this->quiz->id); $this->quba = question_engine::make_questions_usage_by_activity('mod_quiz', $this->quizobj->get_context()); $this->quba->set_preferred_behaviour($this->quizobj->get_quiz()->preferredbehaviour); } /** * Test SQL querry get list attempt in condition. */ public function test_get_list_of_attempts_within_conditions(): void { global $DB; $timenow = time(); // Create an attempt to be completely graded (one hour ago). $attempt1 = quiz_create_attempt($this->quizobj, 1, false, $timenow - HOURSECS, false, $this->student->id); quiz_start_new_attempt($this->quizobj, $this->quba, $attempt1, 1, $timenow - HOURSECS); quiz_attempt_save_started($this->quizobj, $this->quba, $attempt1); // Process some responses from the student (30 mins ago) and submit (20 mins ago). $attemptobj1 = quiz_attempt::create($attempt1->id); $tosubmit = [2 => ['answer' => 'Student 1 answer', 'answerformat' => FORMAT_HTML]]; $attemptobj1->process_submitted_actions($timenow - 30 * MINSECS, false, $tosubmit); $attemptobj1->process_submit($timenow - 20 * MINSECS, false); $attemptobj1->process_grade_submission($timenow - 10 * MINSECS); // Finish the attempt of student (now). $attemptobj1->get_question_usage()->manual_grade(2, 'Good!', 1, FORMAT_HTML); question_engine::save_questions_usage_by_activity($attemptobj1->get_question_usage()); $update = new stdClass(); $update->id = $attemptobj1->get_attemptid(); $update->timemodified = $timenow; $update->sumgrades = $attemptobj1->get_question_usage()->get_total_mark(); $update->gradednotificationsenttime = null; // Processfinish detects the notification is not required, so sets this. // Unset to allow testing of our logic. $DB->update_record('quiz_attempts', $update); $attemptobj1->get_quizobj()->get_grade_calculator()->recompute_final_grade(); // Not quite time to send yet. $task = new quiz_notify_attempt_manual_grading_completed(); $task->set_time_for_testing($timenow + 5 * HOURSECS - 1); $attempts = $task->get_list_of_attempts(); $this->assertEquals(0, iterator_count($attempts)); // After time to send. $task->set_time_for_testing($timenow + 5 * HOURSECS + 1); $attempts = $task->get_list_of_attempts(); $this->assertEquals(1, iterator_count($attempts)); } /** * Test SQL query does not return attempts if the grading is not complete yet. */ public function test_get_list_of_attempts_without_manual_graded(): void { $timenow = time(); // Create an attempt which won't be graded (1 hour ago). $attempt2 = quiz_create_attempt($this->quizobj, 2, false, $timenow - HOURSECS, false, $this->student->id); quiz_start_new_attempt($this->quizobj, $this->quba, $attempt2, 2, $timenow - HOURSECS); quiz_attempt_save_started($this->quizobj, $this->quba, $attempt2); // Process some responses from the student (30 mins ago) and submit (now). $attemptobj2 = quiz_attempt::create($attempt2->id); $tosubmit = [2 => ['answer' => 'Answer of student 2.', 'answerformat' => FORMAT_HTML]]; $attemptobj2->process_submitted_actions($timenow - 30 * MINSECS, false, $tosubmit); $attemptobj2->process_submit($timenow, false); $attemptobj2->process_grade_submission($timenow); // After time to notify, except attempt not graded, so it won't appear. $task = new quiz_notify_attempt_manual_grading_completed(); $task->set_time_for_testing($timenow + 5 * HOURSECS + 1); $attempts = $task->get_list_of_attempts(); $this->assertEquals(0, iterator_count($attempts)); } /** * Test notify manual grading completed task which the user attempt has not capability. */ public function test_notify_manual_grading_completed_task_without_capability(): void { global $DB; // Create an attempt for a user without the capability. $timenow = time(); $timestart = $timenow - HOURSECS; $timefinish = $timenow - 20 * MINSECS; $gradetime = $timenow - 10 * MINSECS; $attempt = quiz_create_attempt($this->quizobj, 3, false, $timenow, false, $this->student->id); quiz_start_new_attempt($this->quizobj, $this->quba, $attempt, 3, $timestart); quiz_attempt_save_started($this->quizobj, $this->quba, $attempt, $timestart); // Process some responses and submit. $attemptobj = quiz_attempt::create($attempt->id); $tosubmit = [2 => ['answer' => 'Essay answer.', 'answerformat' => FORMAT_HTML]]; $attemptobj->process_submitted_actions($timenow - 30 * MINSECS, false, $tosubmit); $attemptobj->process_submit($timefinish, false); $attemptobj->process_grade_submission($gradetime); // Grade the attempt. $attemptobj->get_question_usage()->manual_grade(2, 'Good!', 1, FORMAT_HTML); question_engine::save_questions_usage_by_activity($attemptobj->get_question_usage()); $update = new stdClass(); $update->id = $attemptobj->get_attemptid(); $update->timemodified = $timenow; $update->sumgrades = $attemptobj->get_question_usage()->get_total_mark(); $update->gradednotificationsenttime = null; // Processfinish detects the notification is not required, so sets this. // Unset to allow testing of our logic. $DB->update_record('quiz_attempts', $update); $attemptobj->get_quizobj()->get_grade_calculator()->recompute_final_grade(); // Run the quiz notify attempt manual graded task. $this->expectOutputRegex("~Not sending an email because user does not have mod/quiz:emailnotifyattemptgraded capability.~"); $task = new quiz_notify_attempt_manual_grading_completed(); $task->set_time_for_testing($timenow + 5 * HOURSECS + 1); $task->execute(); $attemptobj = quiz_attempt::create($attempt->id); $this->assertEquals($timefinish, $attemptobj->get_attempt()->gradednotificationsenttime); } /** * Test notify manual grading completed task which the user attempt has capability. */ public function test_notify_manual_grading_completed_task_with_capability(): void { global $DB; // Allow student to receive messages. assign_capability('mod/quiz:emailnotifyattemptgraded', CAP_ALLOW, $this->studentrole->id, $this->context, true); // Create an attempt with capability. $timenow = time(); $attempt = quiz_create_attempt($this->quizobj, 4, false, $timenow, false, $this->student->id); quiz_start_new_attempt($this->quizobj, $this->quba, $attempt, 4, $timenow - HOURSECS); quiz_attempt_save_started($this->quizobj, $this->quba, $attempt); // Process some responses from the student. $attemptobj = quiz_attempt::create($attempt->id); $tosubmit = [2 => ['answer' => 'Answer of student.', 'answerformat' => FORMAT_HTML]]; $attemptobj->process_submitted_actions($timenow - 30 * MINSECS, false, $tosubmit); $attemptobj->process_submit($timenow - 20 * MINSECS, false); $attemptobj->process_grade_submission($timenow - 10 * MINSECS); // Finish the attempt of student. $attemptobj->get_question_usage()->manual_grade(2, 'Good!', 1, FORMAT_HTML); question_engine::save_questions_usage_by_activity($attemptobj->get_question_usage()); $update = new stdClass(); $update->id = $attemptobj->get_attemptid(); $update->timemodified = $timenow; $update->sumgrades = $attemptobj->get_question_usage()->get_total_mark(); $DB->update_record('quiz_attempts', $update); $attemptobj->get_quizobj()->get_grade_calculator()->recompute_final_grade(); // Run the quiz notify attempt manual graded task. $this->expectOutputRegex("~Sending email to user {$this->student->id}~"); $task = new quiz_notify_attempt_manual_grading_completed(); $task->set_time_for_testing($timenow + 5 * HOURSECS + 1); $task->execute(); $attemptobj = quiz_attempt::create($attempt->id); $this->assertNotEquals(null, $attemptobj->get_attempt()->gradednotificationsenttime); $this->assertNotEquals($attemptobj->get_attempt()->timefinish, $attemptobj->get_attempt()->gradednotificationsenttime); } } PK9B\u@=+tests/attempt_walkthrough_from_csv_test.phpnu[. namespace mod_quiz; // phpcs:disable moodle.PHPUnit.TestCaseNames.Missing /** * Quiz attempt walk through using data from csv file. * * @package mod_quiz * @category test * @copyright 2013 The Open University * @author Jamie Pratt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class attempt_walkthrough_from_csv_test extends \mod_quiz\tests\attempt_walkthrough_testcase { #[\Override] protected static function get_test_files(): array { return ['questions', 'steps', 'results']; } } PK9B\j"tests/attempt_walkthrough_test.phpnu[. namespace mod_quiz; use moodle_url; use question_bank; use question_engine; use mod_quiz\tests\question_helper_test_trait; /** * Quiz attempt walk through. * * @package mod_quiz * @category test * @copyright 2013 The Open University * @author Jamie Pratt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @covers \mod_quiz\quiz_attempt */ final class attempt_walkthrough_test extends \advanced_testcase { use question_helper_test_trait; #[\Override] public static function setUpBeforeClass(): void { global $CFG; parent::setUpBeforeClass(); require_once($CFG->dirroot . '/mod/quiz/locallib.php'); } /** * Create a quiz with questions and walk through a quiz attempt. */ public function test_quiz_attempt_walkthrough(): void { global $SITE; $this->resetAfterTest(true); // Make a quiz. $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz'); $quiz = $quizgenerator->create_instance(['course' => $SITE->id, 'questionsperpage' => 0, 'grade' => 100.0, 'sumgrades' => 3]); // Create a couple of questions. $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); $cat = $questiongenerator->create_question_category(); $saq = $questiongenerator->create_question('shortanswer', null, ['category' => $cat->id]); $numq = $questiongenerator->create_question('numerical', null, ['category' => $cat->id]); $matchq = $questiongenerator->create_question('match', null, ['category' => $cat->id]); $description = $questiongenerator->create_question('description', null, ['category' => $cat->id]); // Add them to the quiz. quiz_add_quiz_question($saq->id, $quiz); quiz_add_quiz_question($numq->id, $quiz); quiz_add_quiz_question($matchq->id, $quiz); quiz_add_quiz_question($description->id, $quiz); // Make a user to do the quiz. $user1 = $this->getDataGenerator()->create_user(); $quizobj = quiz_settings::create($quiz->id, $user1->id); // Start the attempt. $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, $user1->id); quiz_start_new_attempt($quizobj, $quba, $attempt, 1, $timenow); $this->assertEquals('1,2,3,4,0', $attempt->layout); quiz_attempt_save_started($quizobj, $quba, $attempt); // Process some responses from the student. $attemptobj = quiz_attempt::create($attempt->id); $this->assertFalse($attemptobj->has_response_to_at_least_one_graded_question()); // The student has not answered any questions. $this->assertEquals(3, $attemptobj->get_number_of_unanswered_questions()); $tosubmit = [1 => ['answer' => 'frog'], 2 => ['answer' => '3.14']]; $attemptobj->process_submitted_actions($timenow, false, $tosubmit); // The student has answered two questions, and only one remaining. $this->assertEquals(1, $attemptobj->get_number_of_unanswered_questions()); $tosubmit = [ 3 => [ 'frog' => 'amphibian', 'cat' => 'mammal', 'newt' => '', ], ]; $attemptobj->process_submitted_actions($timenow, false, $tosubmit); // The student has answered three questions but one is invalid, so there is still one remaining. $this->assertEquals(1, $attemptobj->get_number_of_unanswered_questions()); $tosubmit = [ 3 => [ 'frog' => 'amphibian', 'cat' => 'mammal', 'newt' => 'amphibian', ], ]; $attemptobj->process_submitted_actions($timenow, false, $tosubmit); // The student has answered three questions, so there are no remaining. $this->assertEquals(0, $attemptobj->get_number_of_unanswered_questions()); // Finish the attempt. $attemptobj = quiz_attempt::create($attempt->id); $this->assertTrue($attemptobj->has_response_to_at_least_one_graded_question()); $attemptobj->process_submit($timenow, false); // Re-load quiz attempt data. $attemptobj = quiz_attempt::create($attempt->id); // Check that results are stored as expected. $this->assertEquals(1, $attemptobj->get_attempt_number()); $this->assertEquals(false, $attemptobj->is_finished()); $this->assertEquals($timenow, $attemptobj->get_submitted_date()); $this->assertEquals($user1->id, $attemptobj->get_userid()); $this->assertTrue($attemptobj->has_response_to_at_least_one_graded_question()); $this->assertEquals(0, $attemptobj->get_number_of_unanswered_questions()); // Check we don't have grades yet. $this->assertEmpty(quiz_get_user_grades($quiz, $user1->id)); $this->assertNull($attemptobj->get_sum_marks()); // Now grade the submission. $attemptobj->process_grade_submission($timenow); $attemptobj = quiz_attempt::create($attempt->id); // Check quiz grades. $this->assertEquals(true, $attemptobj->is_finished()); $this->assertEquals(3, $attemptobj->get_sum_marks()); $grades = quiz_get_user_grades($quiz, $user1->id); $grade = array_shift($grades); $this->assertEquals(100.0, $grade->rawgrade); // Check grade book. $gradebookgrades = grade_get_grades($SITE->id, 'mod', 'quiz', $quiz->id, $user1->id); $gradebookitem = array_shift($gradebookgrades->items); $gradebookgrade = array_shift($gradebookitem->grades); $this->assertEquals(100, $gradebookgrade->grade); // Update question in quiz. $newsa = $questiongenerator->update_question( $saq, null, ['name' => 'This is the second version of shortanswer'] ); $newnumbq = $questiongenerator->update_question( $numq, null, ['name' => 'This is the second version of numerical'] ); $newmatch = $questiongenerator->update_question( $matchq, null, ['name' => 'This is the second version of match'] ); $newdescription = $questiongenerator->update_question( $description, null, ['name' => 'This is the second version of description'] ); // Update the attempt to use this questions. // Would not normally be done for a non-preview, but this is just a unit test. $attemptobj->update_questions_to_new_version_if_changed(); // Verify. $this->assertEquals($newsa->id, $attemptobj->get_question_attempt(1)->get_question_id()); $this->assertEquals($newnumbq->id, $attemptobj->get_question_attempt(2)->get_question_id()); $this->assertEquals($newmatch->id, $attemptobj->get_question_attempt(3)->get_question_id()); $this->assertEquals($newdescription->id, $attemptobj->get_question_attempt(4)->get_question_id()); // Repeat the checks from above. $this->assertEquals(1, $attemptobj->get_attempt_number()); $this->assertEquals(3, $attemptobj->get_sum_marks()); $this->assertEquals(true, $attemptobj->is_finished()); $this->assertEquals($timenow, $attemptobj->get_submitted_date()); $this->assertEquals($user1->id, $attemptobj->get_userid()); $this->assertTrue($attemptobj->has_response_to_at_least_one_graded_question()); $this->assertEquals(0, $attemptobj->get_number_of_unanswered_questions()); // Re-load quiz attempt data and repeat the verification. $attemptobj = quiz_attempt::create($attempt->id); $this->assertEquals($newsa->id, $attemptobj->get_question_attempt(1)->get_question_id()); $this->assertEquals($newnumbq->id, $attemptobj->get_question_attempt(2)->get_question_id()); $this->assertEquals($newmatch->id, $attemptobj->get_question_attempt(3)->get_question_id()); $this->assertEquals($newdescription->id, $attemptobj->get_question_attempt(4)->get_question_id()); // Repeat the checks from above. $this->assertEquals(1, $attemptobj->get_attempt_number()); $this->assertEquals(3, $attemptobj->get_sum_marks()); $this->assertEquals(true, $attemptobj->is_finished()); $this->assertEquals($timenow, $attemptobj->get_submitted_date()); $this->assertEquals($user1->id, $attemptobj->get_userid()); $this->assertTrue($attemptobj->has_response_to_at_least_one_graded_question()); $this->assertEquals(0, $attemptobj->get_number_of_unanswered_questions()); } /** * Tests that a quiz attempt with random questions correctly handles cases when question versions have been deleted. * * @param array $deleteversions Array of identifiers for the question versions to be deleted. Valid array values: * - q1v1 (question 1, version 1) * - q1v2 (question 1, version 2) * - q2v1 (question 2, version 1) * - q2v2 (question 2, version 2) * @param bool $canpreview Whether the given user has capability to preview the quiz. * @param array $expectedversions Array of expected question versions per slot after a new attempt following question * version removal. Valid key-value pairs: * - 1 => q1v1 (slot 1; question 1, version 1) * - 1 => q1v2 (slot 1; question 1, version 1) * - 2 => q2v1 (slot 2; question 2, version 1) * - 2 => q2v2 (slot 2; question 2, version 2) * @param string|null $expectedupdateattempterror The expected error message when quiz attempts are updated following * question version removal (if applicable). * @param int|null $expectedslotfewquestions The expected random question slot (number) with insufficient questions * in the category following question version removal (if applicable). * @dataProvider quiz_random_question_deletion_provider */ public function test_quiz_attempt_with_random_questions_when_versions_are_deleted( array $deleteversions, bool $canpreview, array $expectedversions, ?string $expectedupdateattempterror = null, ?int $expectedslotfewquestions = null ): void { global $DB; $this->resetAfterTest(); $this->setAdminUser(); // Create a course and enrol a teacher. $course = $this->getDataGenerator()->create_course(); $teacher = $this->getDataGenerator()->create_user(); $teacherrole = $DB->get_record('role', ['shortname' => 'editingteacher']); $this->getDataGenerator()->enrol_user($teacher->id, $course->id, $teacherrole->id); // Create quiz. $quiz = $this->getDataGenerator()->create_module('quiz', [ 'course' => $course->id, 'grade' => 100.0, 'sumgrades' => 3, ]); // Create question categories. $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); $cat1 = $questiongenerator->create_question_category(); $cat2 = $questiongenerator->create_question_category(); // Create questions. $questions = [ 'q1v1' => $questiongenerator->create_question('shortanswer', null, ['category' => $cat1->id]), 'q2v1' => $questiongenerator->create_question('essay', null, ['category' => $cat2->id]), ]; // Update the questions to generate new versions. $questions['q1v2'] = $questiongenerator->update_question( $questions['q1v1'], null, ['name' => 'This is the latest version'] ); $questions['q2v2'] = $questiongenerator->update_question( $questions['q2v1'], null, ['name' => 'This is the latest version'] ); // Add random questions to quiz. $this->add_random_questions($quiz->id, 1, $cat1->id, 1); $this->add_random_questions($quiz->id, 1, $cat2->id, 1); // Start attempt. $quizobj = quiz_settings::create($quiz->id, $teacher->id); $attempt = quiz_prepare_and_start_new_attempt($quizobj, 1, null, userid: $teacher->id); $attemptobj = quiz_attempt::create($attempt->id); // Delete the question versions defined in the data provider. $deleteids = array_map(fn($label) => $questions[$label]->id, $deleteversions); \qbank_deletequestion\helper::delete_questions($deleteids, false); if (!$canpreview) { // Update the 'mod/quiz:preview' capability as defined by the data provider. $this->setUser($teacher); unassign_capability('mod/quiz:preview', $teacherrole->id); } // Update the quiz attempts. try { $attemptobj->update_questions_to_new_version_if_changed(); // An exception is expected if the last preview/attempt is invalidated due to question versions removed for // this preview/attempt. if ($expectedupdateattempterror) { $this->fail('Exception expected due to preview is deleted'); } } catch (\moodle_exception $e) { // Verify the expected exception message. $this->assertEquals($expectedupdateattempterror, $e->getMessage()); } // An exception is expected if an existing random question lacks sufficient questions in the category due to // question version removal. // The new attempt should fail as a result. if ($expectedslotfewquestions) { $structure = $quizobj->get_structure(); $slot = $structure->get_slot_by_number($expectedslotfewquestions); // Verify the expected exception message. $this->expectExceptionMessage( get_string( 'notenoughrandomquestions', 'quiz', (object) [ 'category' => $slot->category, 'name' => 'Random question', 'id' => $slot->slotid, ] ) ); } // Start a new quiz attempt. $attempt = quiz_prepare_and_start_new_attempt($quizobj, 2, $attempt, userid: $teacher->id); $attemptobj = quiz_attempt::create($attempt->id); // Verify the expected question versions for each slot for the given quiz attempt. foreach ($expectedversions as $slot => $expectedversion) { $this->assertEquals($questions[$expectedversion]->id, $attemptobj->get_question_attempt($slot)->get_question_id()); } } /** * Data provider for test_quiz_attempt_with_random_questions_when_versions_are_deleted. * * @return array */ public static function quiz_random_question_deletion_provider(): array { return [ 'Delete only latest versions for both q1 and q2; quiz preview available' => [ ['q1v2', 'q2v2'], true, [ 1 => 'q1v1', 2 => 'q2v1', ], get_string('attempterrorcontentchange', 'quiz'), ], 'Delete only latest versions for both q1 and q2; quiz preview not available' => [ ['q1v2', 'q2v2'], false, [ 1 => 'q1v1', 2 => 'q2v1', ], get_string('attempterrorcontentchangeforuser', 'quiz'), ], 'Delete all question versions for q1 only; quiz preview available' => [ ['q1v1', 'q1v2'], true, [ 1 => 'q1v1', 2 => 'q2v1', ], get_string('attempterrorcontentchange', 'quiz'), 1, ], 'Delete all question versions for q2 only; quiz preview not available' => [ ['q2v1', 'q2v2'], false, [ 1 => 'q1v1', 2 => 'q2v1', ], get_string('attempterrorcontentchangeforuser', 'quiz'), 2, ], 'Delete only initial question versions for q1 and q2; quiz preview available' => [ ['q1v1', 'q2v1'], true, [ 1 => 'q1v2', 2 => 'q2v2', ], ], ]; } /** * Create a quiz containing one question and a close time. * * The question is the standard shortanswer test question. * The quiz is set to close 1 hour from now. * The quiz is set to use a grade period of 1 hour once time expires. * * @param string $overduehandling value for the overduehandling quiz setting. * @return \stdClass the quiz that was created. */ protected function create_quiz_with_one_question(string $overduehandling = 'graceperiod'): \stdClass { global $SITE; $this->resetAfterTest(); // Make a quiz. $timeclose = time() + HOURSECS; $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz'); $quiz = $quizgenerator->create_instance( ['course' => $SITE->id, 'timeclose' => $timeclose, 'overduehandling' => $overduehandling, 'graceperiod' => HOURSECS] ); // Create a question. /** @var \core_question_generator $questiongenerator */ $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); $cat = $questiongenerator->create_question_category(); $saq = $questiongenerator->create_question('shortanswer', null, ['category' => $cat->id]); // Add them to the quiz. $quizobj = quiz_settings::create($quiz->id); quiz_add_quiz_question($saq->id, $quiz, 0, 1); $quizobj->get_grade_calculator()->recompute_quiz_sumgrades(); return $quiz; } public function test_quiz_attempt_walkthrough_submit_time_recorded_correctly_when_overdue(): void { $quiz = $this->create_quiz_with_one_question(); // Make a user to do the quiz. $user = $this->getDataGenerator()->create_user(); $this->setUser($user); $quizobj = quiz_settings::create($quiz->id, $user->id); // Start the attempt. $attempt = quiz_prepare_and_start_new_attempt($quizobj, 1, null); // Process some responses from the student. $attemptobj = quiz_attempt::create($attempt->id); $this->assertEquals(1, $attemptobj->get_number_of_unanswered_questions()); $attemptobj->process_submitted_actions($quiz->timeclose - 30 * MINSECS, false, [1 => ['answer' => 'frog']]); // Attempt goes overdue (e.g. if cron ran). $attemptobj = quiz_attempt::create($attempt->id); $attemptobj->process_going_overdue($quiz->timeclose + 2 * get_config('quiz', 'graceperiodmin'), false); // Verify the attempt state. $attemptobj = quiz_attempt::create($attempt->id); $this->assertEquals(1, $attemptobj->get_attempt_number()); $this->assertEquals(false, $attemptobj->is_finished()); $this->assertEquals(0, $attemptobj->get_submitted_date()); $this->assertEquals($user->id, $attemptobj->get_userid()); $this->assertTrue($attemptobj->has_response_to_at_least_one_graded_question()); $this->assertEquals(0, $attemptobj->get_number_of_unanswered_questions()); // Student submits the attempt during the grace period. $attemptobj = quiz_attempt::create($attempt->id); $attemptobj->process_attempt($quiz->timeclose + 30 * MINSECS, true, false, 1); // Verify the attempt state. $attemptobj = quiz_attempt::create($attempt->id); $this->assertEquals(1, $attemptobj->get_attempt_number()); $this->assertEquals(true, $attemptobj->is_finished()); $this->assertEquals($quiz->timeclose + 30 * MINSECS, $attemptobj->get_submitted_date()); $this->assertEquals($user->id, $attemptobj->get_userid()); $this->assertTrue($attemptobj->has_response_to_at_least_one_graded_question()); $this->assertEquals(0, $attemptobj->get_number_of_unanswered_questions()); } public function test_quiz_attempt_walkthrough_close_time_extended_at_last_minute(): void { global $DB; $quiz = $this->create_quiz_with_one_question(); $originaltimeclose = $quiz->timeclose; // Make a user to do the quiz. $user = $this->getDataGenerator()->create_user(); $this->setUser($user); $quizobj = quiz_settings::create($quiz->id, $user->id); // Start the attempt. $attempt = quiz_prepare_and_start_new_attempt($quizobj, 1, null); // Process some responses from the student during the attempt. $attemptobj = quiz_attempt::create($attempt->id); $attemptobj->process_submitted_actions($originaltimeclose - 30 * MINSECS, false, [1 => ['answer' => 'frog']]); // Teacher edits the quiz to extend the time-limit by one minute. $DB->set_field('quiz', 'timeclose', $originaltimeclose + MINSECS, ['id' => $quiz->id]); \course_modinfo::clear_instance_cache($quiz->course); // Timer expires in the student browser and thinks it is time to submit the quiz. // This sets $finishattempt to false - since the student did not click the button, and $timeup to true. $attemptobj = quiz_attempt::create($attempt->id); $attemptobj->process_attempt($originaltimeclose, false, true, 1); // Verify the attempt state - the $timeup was ignored becuase things have changed server-side. $attemptobj = quiz_attempt::create($attempt->id); $this->assertEquals(1, $attemptobj->get_attempt_number()); $this->assertFalse($attemptobj->is_finished()); $this->assertEquals(quiz_attempt::IN_PROGRESS, $attemptobj->get_state()); $this->assertEquals(0, $attemptobj->get_submitted_date()); $this->assertEquals($user->id, $attemptobj->get_userid()); } /** * Create a quiz with a random as well as other questions and walk through quiz attempts. */ public function test_quiz_with_random_question_attempt_walkthrough(): void { global $SITE; $this->resetAfterTest(true); question_bank::get_qtype('random')->clear_caches_before_testing(); $this->setAdminUser(); // Make a quiz. $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz'); $quiz = $quizgenerator->create_instance(['course' => $SITE->id, 'questionsperpage' => 2, 'grade' => 100.0, 'sumgrades' => 4]); $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); // Add two questions to question category. $cat = $questiongenerator->create_question_category(); $saq = $questiongenerator->create_question('shortanswer', null, ['category' => $cat->id]); $numq = $questiongenerator->create_question('numerical', null, ['category' => $cat->id]); // Add random question to the quiz. $this->add_random_questions($quiz->id, 0, $cat->id, 1); // Make another category. $cat2 = $questiongenerator->create_question_category(); $match = $questiongenerator->create_question('match', null, ['category' => $cat->id]); quiz_add_quiz_question($match->id, $quiz, 0); $multichoicemulti = $questiongenerator->create_question('multichoice', 'two_of_four', ['category' => $cat->id]); quiz_add_quiz_question($multichoicemulti->id, $quiz, 0); $multichoicesingle = $questiongenerator->create_question('multichoice', 'one_of_four', ['category' => $cat->id]); quiz_add_quiz_question($multichoicesingle->id, $quiz, 0); foreach ([$saq->id => 'frog', $numq->id => '3.14'] as $randomqidtoselect => $randqanswer) { // Make a new user to do the quiz each loop. $user1 = $this->getDataGenerator()->create_user(); $this->setUser($user1); $quizobj = quiz_settings::create($quiz->id, $user1->id); // Start the attempt. $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); quiz_start_new_attempt($quizobj, $quba, $attempt, 1, $timenow, [1 => $randomqidtoselect]); $this->assertEquals('1,2,0,3,4,0', $attempt->layout); quiz_attempt_save_started($quizobj, $quba, $attempt); // Process some responses from the student. $attemptobj = quiz_attempt::create($attempt->id); $this->assertFalse($attemptobj->has_response_to_at_least_one_graded_question()); $this->assertEquals(4, $attemptobj->get_number_of_unanswered_questions()); $tosubmit = []; $selectedquestionid = $quba->get_question_attempt(1)->get_question_id(); $tosubmit[1] = ['answer' => $randqanswer]; $tosubmit[2] = [ 'frog' => 'amphibian', 'cat' => 'mammal', 'newt' => 'amphibian']; $tosubmit[3] = ['One' => '1', 'Two' => '0', 'Three' => '1', 'Four' => '0']; // First and third choice. $tosubmit[4] = ['answer' => 'One']; // The first choice. $attemptobj->process_submitted_actions($timenow, false, $tosubmit); // Finish the attempt. $attemptobj = quiz_attempt::create($attempt->id); $this->assertTrue($attemptobj->has_response_to_at_least_one_graded_question()); $this->assertEquals(0, $attemptobj->get_number_of_unanswered_questions()); $attemptobj->process_submit($timenow, false); // Re-load quiz attempt data. $attemptobj = quiz_attempt::create($attempt->id); // Check that results are stored as expected. $this->assertEquals(1, $attemptobj->get_attempt_number()); $this->assertEquals(false, $attemptobj->is_finished()); $this->assertEquals($timenow, $attemptobj->get_submitted_date()); $this->assertEquals($user1->id, $attemptobj->get_userid()); $this->assertTrue($attemptobj->has_response_to_at_least_one_graded_question()); $this->assertEquals(0, $attemptobj->get_number_of_unanswered_questions()); // Check we don't have grades yet. $this->assertEmpty(quiz_get_user_grades($quiz, $user1->id)); $this->assertNull($attemptobj->get_sum_marks()); // Now grade the submission. $attemptobj->process_grade_submission($timenow); $attemptobj = quiz_attempt::create($attempt->id); // Check quiz grades. $this->assertEquals(true, $attemptobj->is_finished()); $this->assertEquals(4, $attemptobj->get_sum_marks()); // Check quiz grades. $grades = quiz_get_user_grades($quiz, $user1->id); $grade = array_shift($grades); $this->assertEquals(100.0, $grade->rawgrade); // Check grade book. $gradebookgrades = grade_get_grades($SITE->id, 'mod', 'quiz', $quiz->id, $user1->id); $gradebookitem = array_shift($gradebookgrades->items); $gradebookgrade = array_shift($gradebookitem->grades); $this->assertEquals(100, $gradebookgrade->grade); } } /** * Get the correct response for variants. * * @return array */ public static function get_correct_response_for_variants(): array { return [[1, 9.9], [2, 8.5], [5, 14.2], [10, 6.8, true]]; } /** @var ?\quiz A quiz with variants */ protected $quizwithvariants = null; /** * Create a quiz with a single question with variants and walk through quiz attempts. * * @dataProvider get_correct_response_for_variants */ public function test_quiz_with_question_with_variants_attempt_walkthrough($variantno, $correctresponse, $done = false): void { global $SITE; $this->resetAfterTest($done); $this->setAdminUser(); if ($this->quizwithvariants === null) { // Make a quiz. $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz'); $this->quizwithvariants = $quizgenerator->create_instance(['course' => $SITE->id, 'questionsperpage' => 0, 'grade' => 100.0, 'sumgrades' => 1]); $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); $cat = $questiongenerator->create_question_category(); $calc = $questiongenerator->create_question('calculatedsimple', 'sumwithvariants', ['category' => $cat->id]); quiz_add_quiz_question($calc->id, $this->quizwithvariants, 0); } // Make a new user to do the quiz. $user1 = $this->getDataGenerator()->create_user(); $this->setUser($user1); $quizobj = quiz_settings::create($this->quizwithvariants->id, $user1->id); // Start the attempt. $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); // Select variant. quiz_start_new_attempt($quizobj, $quba, $attempt, 1, $timenow, [], [1 => $variantno]); $this->assertEquals('1,0', $attempt->layout); quiz_attempt_save_started($quizobj, $quba, $attempt); // Process some responses from the student. $attemptobj = quiz_attempt::create($attempt->id); $this->assertFalse($attemptobj->has_response_to_at_least_one_graded_question()); $this->assertEquals(1, $attemptobj->get_number_of_unanswered_questions()); $tosubmit = [1 => ['answer' => $correctresponse]]; $attemptobj->process_submitted_actions($timenow, false, $tosubmit); // Finish the attempt. $attemptobj = quiz_attempt::create($attempt->id); $this->assertTrue($attemptobj->has_response_to_at_least_one_graded_question()); $this->assertEquals(0, $attemptobj->get_number_of_unanswered_questions()); $attemptobj->process_submit($timenow, false); // Re-load quiz attempt data. $attemptobj = quiz_attempt::create($attempt->id); // Check that results are stored as expected. $this->assertEquals(1, $attemptobj->get_attempt_number()); $this->assertEquals(false, $attemptobj->is_finished()); $this->assertEquals($timenow, $attemptobj->get_submitted_date()); $this->assertEquals($user1->id, $attemptobj->get_userid()); $this->assertTrue($attemptobj->has_response_to_at_least_one_graded_question()); $this->assertEquals(0, $attemptobj->get_number_of_unanswered_questions()); // Check we don't have grades yet. $this->assertEmpty(quiz_get_user_grades($this->quizwithvariants, $user1->id)); $this->assertNull($attemptobj->get_sum_marks()); // Now grade the submission. $attemptobj->process_grade_submission($timenow); $attemptobj = quiz_attempt::create($attempt->id); // Check quiz grades. $this->assertEquals(true, $attemptobj->is_finished()); $this->assertEquals(1, $attemptobj->get_sum_marks()); // Check quiz grades. $grades = quiz_get_user_grades($this->quizwithvariants, $user1->id); $grade = array_shift($grades); $this->assertEquals(100.0, $grade->rawgrade); // Check grade book. $gradebookgrades = grade_get_grades($SITE->id, 'mod', 'quiz', $this->quizwithvariants->id, $user1->id); $gradebookitem = array_shift($gradebookgrades->items); $gradebookgrade = array_shift($gradebookitem->grades); $this->assertEquals(100, $gradebookgrade->grade); } public function test_quiz_attempt_walkthrough_abandoned_attempt_reopened_with_timelimit_override(): void { global $DB; $quiz = $this->create_quiz_with_one_question('autoabandon'); $originaltimeclose = $quiz->timeclose; // Make a user to do the quiz. $user = $this->getDataGenerator()->create_user(); $this->setUser($user); $quizobj = quiz_settings::create($quiz->id, $user->id); // Start the attempt. $attempt = quiz_prepare_and_start_new_attempt($quizobj, 1, null); // Process some responses from the student during the attempt. $attemptobj = quiz_attempt::create($attempt->id); $attemptobj->process_submitted_actions($originaltimeclose - 30 * MINSECS, false, [1 => ['answer' => 'frog']]); // Student leaves, so cron closes the attempt when time expires. $attemptobj->process_abandon($originaltimeclose + 5 * MINSECS, false); // Verify the attempt state. $attemptobj = quiz_attempt::create($attempt->id); $this->assertEquals(quiz_attempt::ABANDONED, $attemptobj->get_state()); $this->assertEquals(0, $attemptobj->get_submitted_date()); $this->assertEquals($user->id, $attemptobj->get_userid()); // The teacher feels kind, so adds an override for the student, and re-opens the attempt. $sink = $this->redirectEvents(); $overriddentimeclose = $originaltimeclose + HOURSECS; $DB->insert_record('quiz_overrides', [ 'quiz' => $quiz->id, 'userid' => $user->id, 'timeclose' => $overriddentimeclose, ]); $attemptobj = quiz_attempt::create($attempt->id); $reopentime = $originaltimeclose + 10 * MINSECS; $attemptobj->process_reopen_abandoned($reopentime); // Verify the attempt state. $attemptobj = quiz_attempt::create($attempt->id); $this->assertEquals(1, $attemptobj->get_attempt_number()); $this->assertFalse($attemptobj->is_finished()); $this->assertEquals(quiz_attempt::IN_PROGRESS, $attemptobj->get_state()); $this->assertEquals(0, $attemptobj->get_submitted_date()); $this->assertEquals($user->id, $attemptobj->get_userid()); $this->assertEquals( $overriddentimeclose, $attemptobj->get_access_manager($reopentime)->get_end_time($attemptobj->get_attempt()) ); // Verify this was logged correctly. $events = $sink->get_events(); $this->assertCount(1, $events); $reopenedevent = array_shift($events); $this->assertInstanceOf('\mod_quiz\event\attempt_reopened', $reopenedevent); $this->assertEquals($attemptobj->get_context(), $reopenedevent->get_context()); $this->assertEquals( new moodle_url('/mod/quiz/review.php', ['attempt' => $attemptobj->get_attemptid()]), $reopenedevent->get_url() ); } public function test_quiz_attempt_walkthrough_abandoned_attempt_reopened_after_close_time(): void { $quiz = $this->create_quiz_with_one_question('autoabandon'); $originaltimeclose = $quiz->timeclose; // Make a user to do the quiz. $user = $this->getDataGenerator()->create_user(); $this->setUser($user); $quizobj = quiz_settings::create($quiz->id, $user->id); // Start the attempt. $attempt = quiz_prepare_and_start_new_attempt($quizobj, 1, null); // Process some responses from the student during the attempt. $attemptobj = quiz_attempt::create($attempt->id); $attemptobj->process_submitted_actions($originaltimeclose - 30 * MINSECS, false, [1 => ['answer' => 'frog']]); // Student leaves, so cron closes the attempt when time expires. $attemptobj->process_abandon($originaltimeclose + 5 * MINSECS, false); // Verify the attempt state. $attemptobj = quiz_attempt::create($attempt->id); $this->assertEquals(quiz_attempt::ABANDONED, $attemptobj->get_state()); $this->assertEquals(0, $attemptobj->get_submitted_date()); $this->assertEquals($user->id, $attemptobj->get_userid()); // The teacher reopens the attempt without granting more time, so previously submitted responess are graded. $sink = $this->redirectEvents(); $reopentime = $originaltimeclose + 10 * MINSECS; $attemptobj->process_reopen_abandoned($reopentime); // Verify the attempt state. $attemptobj = quiz_attempt::create($attempt->id); $this->assertEquals(1, $attemptobj->get_attempt_number()); $this->assertTrue($attemptobj->is_finished()); $this->assertEquals(quiz_attempt::FINISHED, $attemptobj->get_state()); $this->assertEquals($originaltimeclose, $attemptobj->get_submitted_date()); $this->assertEquals($user->id, $attemptobj->get_userid()); $this->assertEquals(1, $attemptobj->get_sum_marks()); // Verify this was logged correctly - there are some gradebook events between the two we want to check. $events = $sink->get_events(); $this->assertGreaterThanOrEqual(3, $events); $attempturl = new moodle_url( '/mod/quiz/review.php', ['attempt' => $attemptobj->get_attemptid()], ); $reopenedevent = array_shift($events); $this->assertInstanceOf('\mod_quiz\event\attempt_reopened', $reopenedevent); $this->assertEquals($attemptobj->get_context(), $reopenedevent->get_context()); $this->assertEquals($attempturl, $reopenedevent->get_url()); $submittedevent = array_shift($events); $this->assertInstanceOf('\mod_quiz\event\attempt_submitted', $submittedevent); $this->assertEquals($attemptobj->get_context(), $submittedevent->get_context()); $this->assertEquals($attempturl, $submittedevent->get_url()); $gradedevent = array_pop($events); $this->assertInstanceOf('\mod_quiz\event\attempt_graded', $gradedevent); $this->assertEquals($attemptobj->get_context(), $gradedevent->get_context()); $this->assertEquals($attempturl, $gradedevent->get_url()); } /** * Create a quiz with questions, pre-create an attempt, edit a question, then begin the attempt. */ public function test_quiz_attempt_walkthrough_update_question_after_precreate(): void { global $SITE; $this->resetAfterTest(true); // Make a quiz. $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz'); $quiz = $quizgenerator->create_instance( [ 'course' => $SITE->id, 'questionsperpage' => 0, 'grade' => 100.0, 'sumgrades' => 3, ], ); // Create a couple of questions. $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); $cat = $questiongenerator->create_question_category(); $saq = $questiongenerator->create_question('shortanswer', null, ['category' => $cat->id]); $numq = $questiongenerator->create_question('numerical', null, ['category' => $cat->id]); $matchq = $questiongenerator->create_question('match', null, ['category' => $cat->id]); $description = $questiongenerator->create_question('description', null, ['category' => $cat->id]); // Add them to the quiz. quiz_add_quiz_question($saq->id, $quiz); quiz_add_quiz_question($numq->id, $quiz); quiz_add_quiz_question($matchq->id, $quiz); quiz_add_quiz_question($description->id, $quiz); // Make a user to do the quiz. $user1 = $this->getDataGenerator()->create_user(); $quizobj = quiz_settings::create($quiz->id, $user1->id); // Start the attempt. $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, $user1->id); quiz_start_new_attempt($quizobj, $quba, $attempt, 1, $timenow); $this->assertEquals('1,2,3,4,0', $attempt->layout); quiz_attempt_save_not_started($quba, $attempt); $attemptobj = quiz_attempt::create($attempt->id); // Update question in quiz. $newsa = $questiongenerator->update_question($saq, null, ['name' => 'This is the second version of shortanswer']); $newnumbq = $questiongenerator->update_question($numq, null, ['name' => 'This is the second version of numerical']); $newmatch = $questiongenerator->update_question($matchq, null, ['name' => 'This is the second version of match']); $newdescription = $questiongenerator->update_question($description, null, ['name' => 'This is the second version of description']); $this->assertEquals($saq->id, $attemptobj->get_question_attempt(1)->get_question_id()); $this->assertEquals($numq->id, $attemptobj->get_question_attempt(2)->get_question_id()); $this->assertEquals($matchq->id, $attemptobj->get_question_attempt(3)->get_question_id()); $this->assertEquals($description->id, $attemptobj->get_question_attempt(4)->get_question_id()); quiz_attempt_save_started($quizobj, $quba, $attempt); // Verify that the started attempt contains the new questions. $attemptobj = quiz_attempt::create($attempt->id); $this->assertEquals($newsa->id, $attemptobj->get_question_attempt(1)->get_question_id()); $this->assertEquals($newnumbq->id, $attemptobj->get_question_attempt(2)->get_question_id()); $this->assertEquals($newmatch->id, $attemptobj->get_question_attempt(3)->get_question_id()); $this->assertEquals($newdescription->id, $attemptobj->get_question_attempt(4)->get_question_id()); } } PK9B\Cl%l%&tests/quiz_question_bank_view_test.phpnu[. namespace mod_quiz; use core_question\local\bank\question_edit_contexts; use mod_quiz\question\bank\custom_view; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->dirroot . '/question/editlib.php'); /** * Unit tests for the quiz's own question bank view class. * * @package mod_quiz * @category test * @copyright 2018 the Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @covers \mod_quiz\question\bank\custom_view */ final class quiz_question_bank_view_test extends \advanced_testcase { public function test_viewing_question_bank_should_not_load_individual_questions(): 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); $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($questiondata->id); // Generate the view. $params = [ 'qpage' => 0, 'qperpage' => 20, 'cat' => $cat->id . ',' . $context->id, 'recurse' => false, 'showhidden' => false, 'qbshowtext' => false, 'tabname' => 'editq' ]; $extraparams = ['cmid' => $cm->id, 'quizcmid' => $cm->id]; $view = new custom_view($contexts, new \moodle_url('/'), $course, $cm, $params, $extraparams); ob_start(); $view->display(); $html = ob_get_clean(); // Verify the output includes the expected question. $this->assertStringContainsString('Example question', $html); // Verify the question has not been loaded into the cache. $this->assertFalse($cache->has($questiondata->id)); } 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, 'showhidden' => false, 'qbshowtext' => false, 'tabname' => 'editq', ]; $extraparams = ['quizcmid' => $cm->id]; $view = new custom_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); } public function test_viewing_question_bank_when_paging_out_of_limit(): 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); // Create a question in the default category. $contexts = new question_edit_contexts($context); $cm = get_coursemodule_from_instance('quiz', $quiz->id); $cat = question_get_default_category($context->id, true); // Create three questions. $questiongenerator->create_question('numerical', null, ['name' => 'Example question 1', 'category' => $cat->id]); $questiongenerator->create_question('numerical', null, ['name' => 'Example question 2', 'category' => $cat->id]); $question3 = $questiongenerator->create_question('numerical', null, ['name' => 'Example question 3', 'category' => $cat->id]); // Retrieve the question bank view on page 3 with 1 questions per page. $params = [ 'qpage' => 3, 'qperpage' => 1, 'cat' => $cat->id . ',' . $context->id, 'recurse' => false, 'showhidden' => false, 'qbshowtext' => false, 'tabname' => 'editq', ]; // Load the question bank view. $view = new custom_view($contexts, new \moodle_url('/'), $course, $cm, $params, ['quizcmid' => $cm->id]); ob_start(); $view->display(); $html = ob_get_clean(); // Verify that questions exist in the view. $this->assertStringNotContainsString('Example question 1', $html); $this->assertStringNotContainsString('Example question 2', $html); $this->assertStringContainsString('Example question 3', $html); // Set the param per page is 2. // The view only has 2 pages. $params['qperpage'] = 2; // Reload the question bank view on page 3. $view = new custom_view($contexts, new \moodle_url('/'), $course, $cm, $params, ['quizcmid' => $cm->id]); ob_start(); $view->display(); $html = ob_get_clean(); // Since the view has only 2 pages and the requested page is out of range, // the view will move to the nearest available page. // Verify that the view is in the page 2. $this->assertEquals(1, $view->get_pagevars('qpage')); // Verify that questions exist in the view. $this->assertStringNotContainsString('Example question 1', $html); $this->assertStringNotContainsString('Example question 2', $html); $this->assertStringContainsString('Example question 3', $html); // Create a new category. $newcategory = $generator->create_category(); $newcontext = \context_coursecat::instance($newcategory->id); $newquestioncat = $questiongenerator->create_question_category([ 'contextid' => $newcontext->id, ]); // Move question 3 to a new category. question_move_questions_to_category([$question3->id], $newquestioncat->id); // Load the question bank view from the new category. $params['cat'] = $newquestioncat->id . ',' . $newquestioncat->contextid; $view = new custom_view($contexts, new \moodle_url('/'), $course, $cm, $params, ['quizcmid' => $cm->id]); ob_start(); $view->display(); $html = ob_get_clean(); // Verify that the view is in the page 1 and exist only one question. $this->assertEquals(0, $view->get_pagevars('qpage')); $this->assertStringContainsString('Example question 3', $html); } } PK9B\"$#  ,tests/classes/question_helper_test_trait.phpnu[. namespace mod_quiz\tests; use backup; use backup_controller; use component_generator_base; use mod_quiz_generator; use mod_quiz\quiz_attempt; use mod_quiz\quiz_settings; use restore_controller; use stdClass; use question_engine; /** * Helper trait for quiz question unit tests. * * This trait helps to execute different tests for quiz, for example if it needs to create a quiz, add question * to the question, add random quetion to the quiz, do a backup or restore. * * @package mod_quiz * @category test * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ trait question_helper_test_trait { /** @var stdClass $course Test course to contain quiz. */ protected $course; /** @var stdClass $quiz A test quiz. */ protected $quiz; /** @var stdClass $user A test logged-in user. */ protected $user; /** * Create a test quiz for the specified course. * * @param stdClass $course * @return stdClass */ protected function create_test_quiz(stdClass $course): stdClass { /** @var mod_quiz_generator $quizgenerator */ $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz'); return $quizgenerator->create_instance([ 'course' => $course->id, 'questionsperpage' => 0, 'grade' => 100.0, 'sumgrades' => 2, ]); } /** * Helper method to add regular questions in quiz. * * @param component_generator_base $questiongenerator * @param stdClass $quiz * @param array $override */ protected function add_two_regular_questions($questiongenerator, stdClass $quiz, $override = null): void { // Create a couple of questions. $cat = $questiongenerator->create_question_category($override); $saq = $questiongenerator->create_question('shortanswer', null, ['category' => $cat->id]); // Create another version. $questiongenerator->update_question($saq); quiz_add_quiz_question($saq->id, $quiz); $numq = $questiongenerator->create_question('numerical', null, ['category' => $cat->id]); // Create two version. $questiongenerator->update_question($numq); $questiongenerator->update_question($numq); quiz_add_quiz_question($numq->id, $quiz); } /** * Helper method to add random question to quiz. * * @param component_generator_base $questiongenerator * @param stdClass $quiz * @param array $override */ protected function add_one_random_question($questiongenerator, stdClass $quiz, $override = []): void { // Create a random question. $cat = $questiongenerator->create_question_category($override); $questiongenerator->create_question('truefalse', null, ['category' => $cat->id]); $questiongenerator->create_question('essay', null, ['category' => $cat->id]); $this->add_random_questions($quiz->id, 0, $cat->id, 1); } /** * Attempt questions for a quiz and user. * * @param stdClass $quiz Quiz to attempt. * @param stdClass $user A user to attempt the quiz. * @param int $attemptnumber * @return array */ protected function attempt_quiz(stdClass $quiz, stdClass $user, $attemptnumber = 1): array { $this->setUser($user); $starttime = time(); $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); // Start the attempt. $attempt = quiz_create_attempt($quizobj, $attemptnumber, null, $starttime, false, $user->id); quiz_start_new_attempt($quizobj, $quba, $attempt, $attemptnumber, $starttime); quiz_attempt_save_started($quizobj, $quba, $attempt); // Finish the attempt. $attemptobj = quiz_attempt::create($attempt->id); $attemptobj->process_submit($starttime, false); $attemptobj->process_grade_submission($starttime); $this->setUser(); return [$quizobj, $quba, $attemptobj]; } /** * A helper method to backup test quiz. * * @param stdClass $quiz Quiz to attempt. * @param stdClass $user A user to attempt the quiz. * @return string A backup ID ready to be restored. */ protected function backup_quiz(stdClass $quiz, stdClass $user): string { global $CFG; // Get the necessary files to perform backup and restore. require_once($CFG->dirroot . '/backup/util/includes/backup_includes.php'); require_once($CFG->dirroot . '/backup/util/includes/restore_includes.php'); $backupid = 'test-question-backup-restore'; $bc = new backup_controller( backup::TYPE_1ACTIVITY, $quiz->cmid, backup::FORMAT_MOODLE, backup::INTERACTIVE_NO, backup::MODE_GENERAL, $user->id, ); $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(); return $backupid; } /** * A helper method to restore provided backup. * * @param string $backupid Backup ID to restore. * @param stdClass $course * @param stdClass $user */ protected function restore_quiz(string $backupid, stdClass $course, stdClass $user): void { $rc = new restore_controller( $backupid, $course->id, backup::INTERACTIVE_NO, backup::MODE_GENERAL, $user->id, backup::TARGET_CURRENT_ADDING ); $this->assertTrue($rc->execute_precheck()); $rc->execute_plan(); $rc->destroy(); } /** * A helper method to emulate duplication of the quiz. * * @param stdClass $course * @param stdClass $quiz * @return \cm_info|null */ protected function duplicate_quiz($course, $quiz): ?\cm_info { return duplicate_module($course, get_fast_modinfo($course)->get_cm($quiz->cmid)); } /** * Add random questions to a quiz, with a filter condition based on a category ID. * * @param int $quizid The quiz to add the questions to. * @param int $page The page number to add the questions to. * @param int $categoryid The category ID to use for the filter condition. * @param int $number The number of questions to add. * @return void */ protected function add_random_questions(int $quizid, int $page, int $categoryid, int $number): void { $quizobj = quiz_settings::create($quizid); $structure = $quizobj->get_structure(); $filtercondition = [ 'filter' => [ 'category' => [ 'jointype' => \core_question\local\bank\condition::JOINTYPE_DEFAULT, 'values' => [$categoryid], 'filteroptions' => ['includesubcategories' => false], ], ], ]; $structure->add_random_questions($page, $number, $filtercondition); } } PK9B\VgYAYA.tests/classes/attempt_walkthrough_testcase.phpnu[. namespace mod_quiz\tests; use question_engine; use mod_quiz\quiz_settings; use mod_quiz\quiz_attempt; use stdClass; /** * Quiz attempt walk through using data from csv file. * * @package mod_quiz * @category test * @copyright 2013 The Open University * @author Jamie Pratt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ abstract class attempt_walkthrough_testcase extends \advanced_testcase { use question_helper_test_trait; /** * @var stdClass the quiz record we create. */ protected $quiz; /** * @var array with slot no => question name => questionid. Question ids of questions created in the same category as random q. */ protected $randqids; /** * Get the list of files which contain test data. * * @return array */ protected static function get_test_files(): array { return []; } /** * Get the component name. * * @return string */ protected static function get_component(): string { // If the late-static class name is namespaced, use the first part of the namespace. if (str_contains(static::class, '\\')) { return explode('\\', static::class)[0]; } // Otherwise we have to assume that the test name is correctly frankenstyle named. return implode( '_', array_slice( explode('_', static::class, 3), 0, 2, ) ); } /** * Get the full path of the csv file. * * @param string $setname * @param string $test * @return string */ protected static function get_full_path_of_csv_file(string $setname, string $test): string { return static::get_fixture_path(static::get_component(), "{$setname}{$test}.csv"); } /** * The only test in this class. This is run multiple times depending on how many sets of files there are in fixtures/ * directory. * * @param array $quizsettings of settings read from csv file quizzes.csv * @param array $csvdata of data read from csv file "questionsXX.csv", "stepsXX.csv" and "resultsXX.csv". * // phpcs:ignore moodle.Commenting.ValidTags.Invalid * @dataProvider get_data_for_walkthrough */ public function test_walkthrough_from_csv($quizsettings, $csvdata): void { // CSV data files for these tests were generated using: // https://github.com/jamiepratt/moodle-quiz-tools/tree/master/responsegenerator. $this->create_quiz_simulate_attempts_and_check_results($quizsettings, $csvdata); } /** * Create a quiz, add questions to it, and simulate attempts on it. * * @param array $quizsettings Quiz overrides for this quiz. * @param array $csvdata Data loaded from csv files for this test. */ public function create_quiz($quizsettings, $qs) { global $SITE, $DB; $this->setAdminUser(); /** @var core_question_generator $questiongenerator */ $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); $slots = []; $qidsbycat = []; $sumofgrades = 0; foreach ($qs as $qsrow) { $q = $this->explode_dot_separated_keys_to_make_subindexs($qsrow); $catname = ['name' => $q['cat']]; if (!$cat = $DB->get_record('question_categories', ['name' => $q['cat']])) { $cat = $questiongenerator->create_question_category($catname); } $q['catid'] = $cat->id; foreach (['which' => null, 'overrides' => []] as $key => $default) { if (empty($q[$key])) { $q[$key] = $default; } } if ($q['type'] !== 'random') { // Don't actually create random questions here. $overrides = ['category' => $cat->id, 'defaultmark' => $q['mark']] + $q['overrides']; if ($q['type'] === 'truefalse') { // True/false question can never have hints, but sometimes we need to put them // in the CSV file, to keep it rectangular. unset($overrides['hint']); } $question = $questiongenerator->create_question($q['type'], $q['which'], $overrides); $q['id'] = $question->id; if (!isset($qidsbycat[$q['cat']])) { $qidsbycat[$q['cat']] = []; } if (!empty($q['which'])) { $name = $q['type'] . '_' . $q['which']; } else { $name = $q['type']; } $qidsbycat[$q['catid']][$name] = $q['id']; } if (!empty($q['slot'])) { $slots[$q['slot']] = $q; $sumofgrades += $q['mark']; } } ksort($slots); // Make a quiz. $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz'); // Settings from param override defaults. $aggregratedsettings = $quizsettings + ['course' => $SITE->id, 'questionsperpage' => 0, 'grade' => 100.0, 'sumgrades' => $sumofgrades]; $this->quiz = $quizgenerator->create_instance($aggregratedsettings); $this->randqids = []; foreach ($slots as $slotno => $slotquestion) { if ($slotquestion['type'] !== 'random') { quiz_add_quiz_question($slotquestion['id'], $this->quiz, 0, $slotquestion['mark']); } else { $this->add_random_questions($this->quiz->id, 0, $slotquestion['catid'], 1); $this->randqids[$slotno] = $qidsbycat[$slotquestion['catid']]; } } } /** * Create quiz, simulate attempts and check results (if resultsXX.csv exists). * * @param array $quizsettings Quiz overrides for this quiz. * @param array $csvdata Data loaded from csv files for this test. */ protected function create_quiz_simulate_attempts_and_check_results(array $quizsettings, array $csvdata) { $this->resetAfterTest(); $this->create_quiz($quizsettings, $csvdata['questions']); $attemptids = $this->walkthrough_attempts($csvdata['steps']); if (isset($csvdata['results'])) { $this->check_attempts_results($csvdata['results'], $attemptids); } } /** * Load dataset from CSV file "{$setname}{$test}.csv". * * @param string $setname * @param string $test * @return array */ protected static function load_csv_data_file(string $setname, string $test = ''): array { $files = [$setname => static::get_full_path_of_csv_file($setname, $test)]; return static::dataset_from_files($files)->get_rows([$setname]); } /** * Break down row of csv data into sub arrays, according to column names. * * @param array $row from csv file with field names with parts separate by '.'. * @return array the row with each part of the field name following a '.' being a separate sub array's index. */ protected function explode_dot_separated_keys_to_make_subindexs(array $row): array { $parts = []; foreach ($row as $columnkey => $value) { $newkeys = explode('.', trim($columnkey)); $placetoputvalue =& $parts; foreach ($newkeys as $newkeydepth => $newkey) { if ($newkeydepth + 1 === count($newkeys)) { $placetoputvalue[$newkey] = $value; } else { // Going deeper down. if (!isset($placetoputvalue[$newkey])) { $placetoputvalue[$newkey] = []; } $placetoputvalue =& $placetoputvalue[$newkey]; } } } return $parts; } /** * Data provider method for test_walkthrough_from_csv. Called by PHPUnit. * * @return array One array element for each run of the test. Each element contains an array with the params for * test_walkthrough_from_csv. */ public static function get_data_for_walkthrough(): array { $quizzes = self::load_csv_data_file('quizzes')['quizzes']; $datasets = []; foreach ($quizzes as $quizsettings) { $dataset = []; foreach (static::get_test_files() as $file) { if (file_exists(static::get_full_path_of_csv_file($file, $quizsettings['testnumber']))) { $dataset[$file] = self::load_csv_data_file($file, $quizsettings['testnumber'])[$file]; } } $datasets[] = [$quizsettings, $dataset]; } return $datasets; } /** * Helper to walk through attempts. * * @param array $steps the step data from the csv file. * @return array attempt no as in csv file => the id of the quiz_attempt as stored in the db. */ protected function walkthrough_attempts(array $steps): array { global $DB; $attemptids = []; foreach ($steps as $steprow) { $step = $this->explode_dot_separated_keys_to_make_subindexs($steprow); // Find existing user or make a new user to do the quiz. $username = ['firstname' => $step['firstname'], 'lastname' => $step['lastname']]; if (!$user = $DB->get_record('user', $username)) { $user = $this->getDataGenerator()->create_user($username); } if (!isset($attemptids[$step['quizattempt']])) { // Start the attempt. $quizobj = quiz_settings::create($this->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); $prevattempts = quiz_get_user_attempts($this->quiz->id, $user->id, 'all', true); $attemptnumber = count($prevattempts) + 1; $timenow = time(); $attempt = quiz_create_attempt($quizobj, $attemptnumber, null, $timenow, false, $user->id); // Select variant and / or random sub question. if (!isset($step['variants'])) { $step['variants'] = []; } if (isset($step['randqs'])) { // Replace 'names' with ids. foreach ($step['randqs'] as $slotno => $randqname) { $step['randqs'][$slotno] = $this->randqids[$slotno][$randqname]; } } else { $step['randqs'] = []; } quiz_start_new_attempt($quizobj, $quba, $attempt, $attemptnumber, $timenow, $step['randqs'], $step['variants']); quiz_attempt_save_started($quizobj, $quba, $attempt); $attemptid = $attemptids[$step['quizattempt']] = $attempt->id; } else { $attemptid = $attemptids[$step['quizattempt']]; } // Process some responses from the student. $attemptobj = quiz_attempt::create($attemptid); $attemptobj->process_submitted_actions($timenow, false, $step['responses']); // Finish the attempt. if (!isset($step['finished']) || ($step['finished'] == 1)) { $attemptobj = quiz_attempt::create($attemptid); $attemptobj->process_submit($timenow, false); $attemptobj->process_grade_submission($timenow); } } return $attemptids; } /** * Assertion helper to check attempt results. * * @param array $results the results data from the csv file. * @param array $attemptids attempt no as in csv file => the id of the quiz_attempt as stored in the db. */ protected function check_attempts_results(array $results, array $attemptids) { foreach ($results as $resultrow) { $result = $this->explode_dot_separated_keys_to_make_subindexs($resultrow); // Re-load quiz attempt data. $attemptobj = quiz_attempt::create($attemptids[$result['quizattempt']]); $this->check_attempt_results($result, $attemptobj); } } /** * Check that attempt results are as specified in $result. * * @param array $result row of data read from csv file. * @param quiz_attempt $attemptobj the attempt object loaded from db. */ protected function check_attempt_results(array $result, quiz_attempt $attemptobj) { foreach ($result as $fieldname => $value) { if ($value === '!NULL!') { $value = null; } switch ($fieldname) { case 'quizattempt': break; case 'attemptnumber': $this->assertEquals($value, $attemptobj->get_attempt_number()); break; case 'slots': foreach ($value as $slotno => $slottests) { foreach ($slottests as $slotfieldname => $slotvalue) { switch ($slotfieldname) { case 'mark': $this->assertEquals( round($slotvalue, 2), $attemptobj->get_question_mark($slotno), "Mark for slot $slotno of attempt {$result['quizattempt']}." ); break; default: throw new \coding_exception('Unknown slots sub field column in csv file ' . s($slotfieldname)); } } } break; case 'finished': $this->assertEquals((bool)$value, $attemptobj->is_finished()); break; case 'summarks': $this->assertEquals( (float)$value, $attemptobj->get_sum_marks(), "Sum of marks of attempt {$result['quizattempt']}." ); break; case 'quizgrade': // Check quiz grades. $grades = quiz_get_user_grades($attemptobj->get_quiz(), $attemptobj->get_userid()); $grade = array_shift($grades); $this->assertEquals($value, $grade->rawgrade, "Quiz grade for attempt {$result['quizattempt']}."); break; case 'gradebookgrade': // Check grade book. $gradebookgrades = grade_get_grades( $attemptobj->get_courseid(), 'mod', 'quiz', $attemptobj->get_quizid(), $attemptobj->get_userid() ); $gradebookitem = array_shift($gradebookgrades->items); $gradebookgrade = array_shift($gradebookitem->grades); $this->assertEquals($value, $gradebookgrade->grade, "Gradebook grade for attempt {$result['quizattempt']}."); break; default: throw new \coding_exception('Unknown column in csv file ' . s($fieldname)); } } } } PK9B\^c;wwtests/locallib_test.phpnu[. /** * Unit tests for (some of) mod/quiz/locallib.php. * * @package mod_quiz * @category test * @copyright 2008 Tim Hunt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace mod_quiz; use mod_quiz\output\renderer; use mod_quiz\question\display_options; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->dirroot . '/mod/quiz/locallib.php'); require_once($CFG->dirroot . '/mod/quiz/tests/quiz_question_helper_test_trait.php'); /** * Unit tests for (some of) mod/quiz/locallib.php. * * @copyright 2008 Tim Hunt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class locallib_test extends \advanced_testcase { use \quiz_question_helper_test_trait; public function test_quiz_rescale_grade(): void { $quiz = new \stdClass(); $quiz->decimalpoints = 2; $quiz->questiondecimalpoints = 3; $quiz->grade = 10; $quiz->sumgrades = 10; $this->assertEquals(quiz_rescale_grade(0.12345678, $quiz, false), 0.12345678); $this->assertEquals(quiz_rescale_grade(0.12345678, $quiz, true), format_float(0.12, 2)); $this->assertEquals(quiz_rescale_grade(0.12345678, $quiz, 'question'), format_float(0.123, 3)); $quiz->sumgrades = 5; $this->assertEquals(quiz_rescale_grade(0.12345678, $quiz, false), 0.24691356); $this->assertEquals(quiz_rescale_grade(0.12345678, $quiz, true), format_float(0.25, 2)); $this->assertEquals(quiz_rescale_grade(0.12345678, $quiz, 'question'), format_float(0.247, 3)); } public static function quiz_attempt_state_data_provider(): array { return [ [quiz_attempt::IN_PROGRESS, null, null, display_options::DURING], [quiz_attempt::FINISHED, -90, null, display_options::IMMEDIATELY_AFTER], [quiz_attempt::FINISHED, -7200, null, display_options::LATER_WHILE_OPEN], [quiz_attempt::FINISHED, -7200, 3600, display_options::LATER_WHILE_OPEN], [quiz_attempt::FINISHED, -30, 30, display_options::IMMEDIATELY_AFTER], [quiz_attempt::FINISHED, -90, -30, display_options::AFTER_CLOSE], [quiz_attempt::FINISHED, -7200, -3600, display_options::AFTER_CLOSE], [quiz_attempt::FINISHED, -90, -3600, display_options::AFTER_CLOSE], [quiz_attempt::ABANDONED, -10000000, null, display_options::LATER_WHILE_OPEN], [quiz_attempt::ABANDONED, -7200, 3600, display_options::LATER_WHILE_OPEN], [quiz_attempt::ABANDONED, -7200, -3600, display_options::AFTER_CLOSE], ]; } /** * @dataProvider quiz_attempt_state_data_provider * * @param string $attemptstate as in the quiz_attempts.state DB column. * @param int|null $relativetimefinish time relative to now when the attempt finished, or null for 0. * @param int|null $relativetimeclose time relative to now when the quiz closes, or null for 0. * @param int $expectedstate expected result. One of the display_options constants. * @covers ::quiz_attempt_state */ public function test_quiz_attempt_state(string $attemptstate, ?int $relativetimefinish, ?int $relativetimeclose, int $expectedstate): void { $attempt = new \stdClass(); $attempt->state = $attemptstate; if ($relativetimefinish === null) { $attempt->timefinish = 0; } else { $attempt->timefinish = time() + $relativetimefinish; } $quiz = new \stdClass(); if ($relativetimeclose === null) { $quiz->timeclose = 0; } else { $quiz->timeclose = time() + $relativetimeclose; } $this->assertEquals($expectedstate, quiz_attempt_state($quiz, $attempt)); } /** * @covers ::quiz_question_tostring */ public function test_quiz_question_tostring(): void { $question = new \stdClass(); $question->qtype = 'multichoice'; $question->name = 'The question name'; $question->questiontext = '

                What sort of inequality is x < y?

                '; $question->questiontextformat = FORMAT_HTML; $summary = quiz_question_tostring($question); $this->assertEquals('The question name ' . 'What sort of INEQUALITY is x < y[?]' . "\n" . '', $summary); } /** * Test the method quiz_question_to_string with the tag display. * * @covers ::quiz_question_tostring */ public function test_quiz_question_tostring_with_tags(): void { $this->resetAfterTest(); $context = \context_coursecat::instance($this->getDataGenerator()->create_category()->id); $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); $questioncat = $questiongenerator->create_question_category(['contextid' => $context->id]); // Create a question. $question = $questiongenerator->create_question('shortanswer', null, ['category' => $questioncat->id]); // Add tag to question. \core_tag_tag::set_item_tags('core_question', 'question', $question->id, $context, ['Banana']); // Retrieve the question text to display, including the tag, with the tag displayed as a link. $summary = quiz_question_tostring(question: $question, showtags: true); // Ensure the tag is enclosed within a link. $this->assertMatchesRegularExpression('/]*>\s*Banana\s*<\/a>/', $summary); // Retrieve the question text to display, including the tag, but ensure the tag is not displayed as a link. $summary = quiz_question_tostring(question: $question, showtags: true, displaytaglink: false); $this->assertMatchesRegularExpression('/]*>\s*Banana\s*<\/span>/', $summary); } /** * @covers ::quiz_question_tostring */ public function test_quiz_question_tostring_does_not_filter(): void { $question = new \stdClass(); $question->qtype = 'multichoice'; $question->name = 'The question name'; $question->questiontext = '

                No emoticons here :-)

                '; $question->questiontextformat = FORMAT_HTML; $summary = quiz_question_tostring($question); $this->assertEquals('The question name ' . 'No emoticons here :-)' . "\n", $summary); } /** * Test quiz_view * @return void */ public function test_quiz_view(): void { global $CFG; $CFG->enablecompletion = 1; $this->resetAfterTest(); $this->setAdminUser(); // Setup test data. $course = $this->getDataGenerator()->create_course(['enablecompletion' => 1]); $quiz = $this->getDataGenerator()->create_module('quiz', ['course' => $course->id], ['completion' => 2, 'completionview' => 1]); $context = \context_module::instance($quiz->cmid); $cm = get_coursemodule_from_instance('quiz', $quiz->id); // Trigger and capture the event. $sink = $this->redirectEvents(); quiz_view($quiz, $course, $cm, $context); $events = $sink->get_events(); // 2 additional events thanks to completion. $this->assertCount(3, $events); $event = array_shift($events); // Checking that the event contains the expected values. $this->assertInstanceOf('\mod_quiz\event\course_module_viewed', $event); $this->assertEquals($context, $event->get_context()); $moodleurl = new \moodle_url('/mod/quiz/view.php', ['id' => $cm->id]); $this->assertEquals($moodleurl, $event->get_url()); $this->assertEventContextNotUsed($event); $this->assertNotEmpty($event->get_name()); // Check completion status. $completion = new \completion_info($course); $completiondata = $completion->get_data($cm); $this->assertEquals(1, $completiondata->completionstate); } /** * Return false when there are not overrides for this quiz instance. */ public function test_quiz_is_overriden_calendar_event_no_override(): void { global $CFG, $DB; $this->resetAfterTest(); $this->setAdminUser(); $generator = $this->getDataGenerator(); $user = $generator->create_user(); $course = $generator->create_course(); $quizgenerator = $generator->get_plugin_generator('mod_quiz'); $quiz = $quizgenerator->create_instance(['course' => $course->id]); $event = new \calendar_event((object)[ 'modulename' => 'quiz', 'instance' => $quiz->id, 'userid' => $user->id ]); $this->assertFalse(quiz_is_overriden_calendar_event($event)); } /** * Return false if the given event isn't an quiz module event. */ public function test_quiz_is_overriden_calendar_event_no_module_event(): void { global $CFG, $DB; $this->resetAfterTest(); $this->setAdminUser(); $generator = $this->getDataGenerator(); $user = $generator->create_user(); $course = $generator->create_course(); $quizgenerator = $generator->get_plugin_generator('mod_quiz'); $quiz = $quizgenerator->create_instance(['course' => $course->id]); $event = new \calendar_event((object)[ 'userid' => $user->id ]); $this->assertFalse(quiz_is_overriden_calendar_event($event)); } /** * Return false if there is overrides for this use but they belong to another quiz * instance. */ public function test_quiz_is_overriden_calendar_event_different_quiz_instance(): void { global $CFG, $DB; $this->resetAfterTest(); $this->setAdminUser(); $generator = $this->getDataGenerator(); $user = $generator->create_user(); $course = $generator->create_course(); $quizgenerator = $generator->get_plugin_generator('mod_quiz'); $quiz = $quizgenerator->create_instance(['course' => $course->id]); $quiz2 = $quizgenerator->create_instance(['course' => $course->id]); $event = new \calendar_event((object) [ 'modulename' => 'quiz', 'instance' => $quiz->id, 'userid' => $user->id ]); $record = (object) [ 'quiz' => $quiz2->id, 'userid' => $user->id ]; $DB->insert_record('quiz_overrides', $record); $this->assertFalse(quiz_is_overriden_calendar_event($event)); } /** * Return true if there is a user override for this event and quiz instance. */ public function test_quiz_is_overriden_calendar_event_user_override(): void { global $CFG, $DB; $this->resetAfterTest(); $this->setAdminUser(); $generator = $this->getDataGenerator(); $user = $generator->create_user(); $course = $generator->create_course(); $quizgenerator = $generator->get_plugin_generator('mod_quiz'); $quiz = $quizgenerator->create_instance(['course' => $course->id]); $event = new \calendar_event((object) [ 'modulename' => 'quiz', 'instance' => $quiz->id, 'userid' => $user->id ]); $record = (object) [ 'quiz' => $quiz->id, 'userid' => $user->id ]; $DB->insert_record('quiz_overrides', $record); $this->assertTrue(quiz_is_overriden_calendar_event($event)); } /** * Return true if there is a group override for the event and quiz instance. */ public function test_quiz_is_overriden_calendar_event_group_override(): void { global $CFG, $DB; $this->resetAfterTest(); $this->setAdminUser(); $generator = $this->getDataGenerator(); $user = $generator->create_user(); $course = $generator->create_course(); $quizgenerator = $generator->get_plugin_generator('mod_quiz'); $quiz = $quizgenerator->create_instance(['course' => $course->id]); $group = $this->getDataGenerator()->create_group(['courseid' => $quiz->course]); $groupid = $group->id; $userid = $user->id; $event = new \calendar_event((object) [ 'modulename' => 'quiz', 'instance' => $quiz->id, 'groupid' => $groupid ]); $record = (object) [ 'quiz' => $quiz->id, 'groupid' => $groupid ]; $DB->insert_record('quiz_overrides', $record); $this->assertTrue(quiz_is_overriden_calendar_event($event)); } /** * Test test_quiz_get_user_timeclose(). */ public function test_quiz_get_user_timeclose(): void { global $DB; $this->resetAfterTest(); $this->setAdminUser(); $basetimestamp = time(); // The timestamp we will base the enddates on. // Create generator, course and quizzes. $student1 = $this->getDataGenerator()->create_user(); $student2 = $this->getDataGenerator()->create_user(); $student3 = $this->getDataGenerator()->create_user(); $teacher = $this->getDataGenerator()->create_user(); $course = $this->getDataGenerator()->create_course(); $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz'); // Both quizzes close in two hours. $quiz1 = $quizgenerator->create_instance(['course' => $course->id, 'timeclose' => $basetimestamp + 7200]); $quiz2 = $quizgenerator->create_instance(['course' => $course->id, 'timeclose' => $basetimestamp + 7200]); $group1 = $this->getDataGenerator()->create_group(['courseid' => $course->id]); $group2 = $this->getDataGenerator()->create_group(['courseid' => $course->id]); $student1id = $student1->id; $student2id = $student2->id; $student3id = $student3->id; $teacherid = $teacher->id; // Users enrolments. $studentrole = $DB->get_record('role', ['shortname' => 'student']); $teacherrole = $DB->get_record('role', ['shortname' => 'editingteacher']); $this->getDataGenerator()->enrol_user($student1id, $course->id, $studentrole->id, 'manual'); $this->getDataGenerator()->enrol_user($student2id, $course->id, $studentrole->id, 'manual'); $this->getDataGenerator()->enrol_user($student3id, $course->id, $studentrole->id, 'manual'); $this->getDataGenerator()->enrol_user($teacherid, $course->id, $teacherrole->id, 'manual'); // Create groups. $group1 = $this->getDataGenerator()->create_group(['courseid' => $course->id]); $group2 = $this->getDataGenerator()->create_group(['courseid' => $course->id]); $group1id = $group1->id; $group2id = $group2->id; $this->getDataGenerator()->create_group_member(['userid' => $student1id, 'groupid' => $group1id]); $this->getDataGenerator()->create_group_member(['userid' => $student2id, 'groupid' => $group2id]); // Group 1 gets an group override for quiz 1 to close in three hours. $record1 = (object) [ 'quiz' => $quiz1->id, 'groupid' => $group1id, 'timeclose' => $basetimestamp + 10800 // In three hours. ]; $DB->insert_record('quiz_overrides', $record1); // Let's test quiz 1 closes in three hours for user student 1 since member of group 1. // Quiz 2 closes in two hours. $this->setUser($student1id); $params = new \stdClass(); $comparearray = []; $object = new \stdClass(); $object->id = $quiz1->id; $object->usertimeclose = $basetimestamp + 10800; // The overriden timeclose for quiz 1. $comparearray[$quiz1->id] = $object; $object = new \stdClass(); $object->id = $quiz2->id; $object->usertimeclose = $basetimestamp + 7200; // The unchanged timeclose for quiz 2. $comparearray[$quiz2->id] = $object; $this->assertEquals($comparearray, quiz_get_user_timeclose($course->id)); // Let's test quiz 1 closes in two hours (the original value) for user student 3 since member of no group. $this->setUser($student3id); $params = new \stdClass(); $comparearray = []; $object = new \stdClass(); $object->id = $quiz1->id; $object->usertimeclose = $basetimestamp + 7200; // The original timeclose for quiz 1. $comparearray[$quiz1->id] = $object; $object = new \stdClass(); $object->id = $quiz2->id; $object->usertimeclose = $basetimestamp + 7200; // The original timeclose for quiz 2. $comparearray[$quiz2->id] = $object; $this->assertEquals($comparearray, quiz_get_user_timeclose($course->id)); // User 2 gets an user override for quiz 1 to close in four hours. $record2 = (object) [ 'quiz' => $quiz1->id, 'userid' => $student2id, 'timeclose' => $basetimestamp + 14400 // In four hours. ]; $DB->insert_record('quiz_overrides', $record2); // Let's test quiz 1 closes in four hours for user student 2 since personally overriden. // Quiz 2 closes in two hours. $this->setUser($student2id); $comparearray = []; $object = new \stdClass(); $object->id = $quiz1->id; $object->usertimeclose = $basetimestamp + 14400; // The overriden timeclose for quiz 1. $comparearray[$quiz1->id] = $object; $object = new \stdClass(); $object->id = $quiz2->id; $object->usertimeclose = $basetimestamp + 7200; // The unchanged timeclose for quiz 2. $comparearray[$quiz2->id] = $object; $this->assertEquals($comparearray, quiz_get_user_timeclose($course->id)); // Let's test a teacher sees the original times. // Quiz 1 and quiz 2 close in two hours. $this->setUser($teacherid); $comparearray = []; $object = new \stdClass(); $object->id = $quiz1->id; $object->usertimeclose = $basetimestamp + 7200; // The unchanged timeclose for quiz 1. $comparearray[$quiz1->id] = $object; $object = new \stdClass(); $object->id = $quiz2->id; $object->usertimeclose = $basetimestamp + 7200; // The unchanged timeclose for quiz 2. $comparearray[$quiz2->id] = $object; $this->assertEquals($comparearray, quiz_get_user_timeclose($course->id)); } /** * This function creates a quiz with some standard (non-random) and some random questions. * The standard questions are created first and then random questions follow them. * So in a quiz with 3 standard question and 2 random question, the first random question is at slot 4. * * @param int $qnum Number of standard questions that should be created in the quiz. * @param int $randomqnum Number of random questions that should be created in the quiz. * @param array $questiontags Tags to be used for random questions. * This is an array in the following format: * [ * 0 => ['foo', 'bar'], * 1 => ['baz', 'qux'] * ] * @param string[] $unusedtags Some additional tags to be created. * @return array An array of 2 elements: $quiz and $tagobjects. * $tagobjects is an associative array of all created tag objects with its key being tag names. */ private function setup_quiz_and_tags($qnum, $randomqnum, $questiontags = [], $unusedtags = []) { global $SITE; $tagobjects = []; // Get all the tags that need to be created. $alltags = []; foreach ($questiontags as $questiontag) { $alltags = array_merge($alltags, $questiontag); } $alltags = array_merge($alltags, $unusedtags); $alltags = array_unique($alltags); // Create tags. foreach ($alltags as $tagname) { $tagrecord = [ 'isstandard' => 1, 'flag' => 0, 'rawname' => $tagname, 'description' => $tagname . ' desc' ]; $tagobjects[$tagname] = $this->getDataGenerator()->create_tag($tagrecord); } // Create a quiz. $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz'); $quiz = $quizgenerator->create_instance(['course' => $SITE->id, 'questionsperpage' => 3, 'grade' => 100.0]); // Create a question category in the system context. $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); $cat = $questiongenerator->create_question_category(); // Setup standard questions. for ($i = 0; $i < $qnum; $i++) { $question = $questiongenerator->create_question('shortanswer', null, ['category' => $cat->id]); quiz_add_quiz_question($question->id, $quiz); } // Setup random questions. for ($i = 0; $i < $randomqnum; $i++) { // Just create a standard question first, so there would be enough questions to pick a random question from. $question = $questiongenerator->create_question('shortanswer', null, ['category' => $cat->id]); $tagids = []; if (!empty($questiontags[$i])) { foreach ($questiontags[$i] as $tagname) { $tagids[] = $tagobjects[$tagname]->id; } } $this->add_random_questions($quiz->id, 0, $cat->id, 1); } return [$quiz, $tagobjects]; } public function test_quiz_override_summary(): void { global $DB, $PAGE; $this->resetAfterTest(); $generator = $this->getDataGenerator(); /** @var mod_quiz_generator $quizgenerator */ $quizgenerator = $generator->get_plugin_generator('mod_quiz'); /** @var renderer $renderer */ $renderer = $PAGE->get_renderer('mod_quiz'); // Course with quiz and a group - plus some others, to verify they don't get counted. $course = $generator->create_course(); $quiz = $quizgenerator->create_instance(['course' => $course->id, 'groupmode' => SEPARATEGROUPS]); $cm = get_coursemodule_from_id('quiz', $quiz->cmid, $course->id); $group = $generator->create_group(['courseid' => $course->id]); $othergroup = $generator->create_group(['courseid' => $course->id]); $otherquiz = $quizgenerator->create_instance(['course' => $course->id]); // Initial test (as admin) with no data. $this->setAdminUser(); $this->assertEquals(['group' => 0, 'user' => 0, 'mode' => 'allgroups'], quiz_override_summary($quiz, $cm)); $this->assertEquals(['group' => 0, 'user' => 0, 'mode' => 'onegroup'], quiz_override_summary($quiz, $cm, $group->id)); // Editing teacher. $teacher = $generator->create_user(); $generator->enrol_user($teacher->id, $course->id, 'editingteacher'); // Non-editing teacher. $tutor = $generator->create_user(); $generator->enrol_user($tutor->id, $course->id, 'teacher'); $generator->create_group_member(['userid' => $tutor->id, 'groupid' => $group->id]); // Three students. $student1 = $generator->create_user(); $generator->enrol_user($student1->id, $course->id, 'student'); $generator->create_group_member(['userid' => $student1->id, 'groupid' => $group->id]); $student2 = $generator->create_user(); $generator->enrol_user($student2->id, $course->id, 'student'); $generator->create_group_member(['userid' => $student2->id, 'groupid' => $othergroup->id]); $student3 = $generator->create_user(); $generator->enrol_user($student3->id, $course->id, 'student'); // Initial test now users exist, but before overrides. // Test as teacher. $this->setUser($teacher); $this->assertEquals(['group' => 0, 'user' => 0, 'mode' => 'allgroups'], quiz_override_summary($quiz, $cm)); $this->assertEquals(['group' => 0, 'user' => 0, 'mode' => 'onegroup'], quiz_override_summary($quiz, $cm, $group->id)); // Test as tutor. $this->setUser($tutor); $this->assertEquals(['group' => 0, 'user' => 0, 'mode' => 'somegroups'], quiz_override_summary($quiz, $cm)); $this->assertEquals(['group' => 0, 'user' => 0, 'mode' => 'onegroup'], quiz_override_summary($quiz, $cm, $group->id)); $this->assertEquals('', $renderer->quiz_override_summary_links($quiz, $cm)); // Quiz setting overrides for students 1 and 3. $quizgenerator->create_override(['quiz' => $quiz->id, 'userid' => $student1->id, 'attempts' => 2]); $quizgenerator->create_override(['quiz' => $quiz->id, 'userid' => $student3->id, 'attempts' => 2]); $quizgenerator->create_override(['quiz' => $quiz->id, 'groupid' => $group->id, 'attempts' => 3]); $quizgenerator->create_override(['quiz' => $quiz->id, 'groupid' => $othergroup->id, 'attempts' => 3]); $quizgenerator->create_override(['quiz' => $otherquiz->id, 'userid' => $student2->id, 'attempts' => 2]); // Test as teacher. $this->setUser($teacher); $this->assertEquals(['group' => 2, 'user' => 2, 'mode' => 'allgroups'], quiz_override_summary($quiz, $cm)); $this->assertEquals('Settings overrides exist (Groups: 2, Users: 2)', // Links checked by Behat, so strip them for these tests. html_to_text($renderer->quiz_override_summary_links($quiz, $cm), 0, false)); $this->assertEquals(['group' => 1, 'user' => 1, 'mode' => 'onegroup'], quiz_override_summary($quiz, $cm, $group->id)); $this->assertEquals('Settings overrides exist (Groups: 1, Users: 1) for this group', html_to_text($renderer->quiz_override_summary_links($quiz, $cm, $group->id), 0, false)); // Test as tutor. $this->setUser($tutor); $this->assertEquals(['group' => 1, 'user' => 1, 'mode' => 'somegroups'], quiz_override_summary($quiz, $cm)); $this->assertEquals('Settings overrides exist (Groups: 1, Users: 1) for your groups', html_to_text($renderer->quiz_override_summary_links($quiz, $cm), 0, false)); $this->assertEquals(['group' => 1, 'user' => 1, 'mode' => 'onegroup'], quiz_override_summary($quiz, $cm, $group->id)); $this->assertEquals('Settings overrides exist (Groups: 1, Users: 1) for this group', html_to_text($renderer->quiz_override_summary_links($quiz, $cm, $group->id), 0, false)); // Now set the quiz to be group mode: no groups, and re-test as tutor. // In this case, the tutor should see all groups. $DB->set_field('course_modules', 'groupmode', NOGROUPS, ['id' => $cm->id]); $cm = get_coursemodule_from_id('quiz', $quiz->cmid, $course->id); $this->assertEquals(['group' => 2, 'user' => 2, 'mode' => 'allgroups'], quiz_override_summary($quiz, $cm)); $this->assertEquals('Settings overrides exist (Groups: 2, Users: 2)', html_to_text($renderer->quiz_override_summary_links($quiz, $cm), 0, false)); } /** * Test quiz_send_confirmation function. */ public function test_quiz_send_confirmation(): void { global $CFG, $DB; $this->resetAfterTest(); $this->setAdminUser(); $this->preventResetByRollback(); $course = $this->getDataGenerator()->create_course(); $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz'); $quiz = $quizgenerator->create_instance(['course' => $course->id]); $cm = get_coursemodule_from_instance('quiz', $quiz->id); $recipient = $this->getDataGenerator()->create_user(['email' => 'student@example.com']); // Allow recipent to receive email confirm submission. $studentrole = $DB->get_record('role', ['shortname' => 'student']); assign_capability('mod/quiz:emailconfirmsubmission', CAP_ALLOW, $studentrole->id, \context_course::instance($course->id), true); $this->getDataGenerator()->enrol_user($recipient->id, $course->id, $studentrole->id, 'manual'); $timenow = time(); $data = new \stdClass(); // Course info. $data->courseid = $course->id; $data->coursename = $course->fullname; // Quiz info. $data->quizname = $quiz->name; $data->quizurl = $CFG->wwwroot . '/mod/quiz/view.php?id=' . $cm->id; $data->quizid = $quiz->id; $data->quizcmid = $quiz->cmid; $data->attemptid = 1; $data->submissiontime = userdate($timenow); $sink = $this->redirectEmails(); quiz_send_confirmation($recipient, $data, true); $messages = $sink->get_messages(); $message = reset($messages); $this->assertStringContainsString("Thank you for submitting your answers" , quoted_printable_decode($message->body)); $sink->close(); $sink = $this->redirectEmails(); quiz_send_confirmation($recipient, $data, false); $messages = $sink->get_messages(); $message = reset($messages); $this->assertStringContainsString("Your answers were submitted automatically" , quoted_printable_decode($message->body)); $sink->close(); } } PK9B\ $ $tests/quiz_question_restore_test.phpnu[. namespace mod_quiz; use core\exception\coding_exception; use core_question\local\bank\random_question_loader; use core_question\question_reference_manager; use mod_quiz\question\display_options; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once(__DIR__ . '/quiz_question_helper_test_trait.php'); require_once($CFG->dirroot . '/backup/util/includes/restore_includes.php'); require_once($CFG->dirroot . '/mod/quiz/locallib.php'); /** * Quiz backup and restore tests. * * @package mod_quiz * @category test * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @covers \mod_quiz\question\bank\qbank_helper * @covers \restore_quiz_activity_structure_step * @covers \restore_question_set_reference_data_trait */ final class quiz_question_restore_test extends \advanced_testcase { use \quiz_question_helper_test_trait; /** * @var \stdClass test student user. */ protected $student; /** * Called before every test. */ public function setUp(): void { global $USER; parent::setUp(); $this->setAdminUser(); $this->course = $this->getDataGenerator()->create_course(); $this->student = $this->getDataGenerator()->create_user(); $this->user = $USER; } /** * Create a quiz with 2 questions and 1 random question, using question categories in the provided context. * * @param int $questioncontextid Context to create question categories in. * @return array The quiz, slots, random questions in each slot that has them, and the quiz context. */ public function create_quiz_with_questions(int $questioncontextid): array { $quiz = $this->create_test_quiz($this->course); $quizcontext = \context_module::instance($quiz->cmid); // Test for questions from a different context. $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); $this->add_two_regular_questions($questiongenerator, $quiz, ['contextid' => $questioncontextid]); $this->add_one_random_question($questiongenerator, $quiz, ['contextid' => $questioncontextid]); $slots = \mod_quiz\question\bank\qbank_helper::get_question_structure( $quiz->id, $quizcontext); $randomquestions = []; $loader = new random_question_loader(new \qubaid_list([])); foreach ($slots as $number => $slot) { if (!empty($slot->filtercondition)) { $randomquestions[$number] = $loader->get_filtered_questions($slot->filtercondition['filter']); } } return [ $quiz, $slots, $randomquestions, $quizcontext, ]; } /** * Verify the layout of the quiz on the provided course matches (or doesn't match) the expected layout. * * For $expectedslots, the value should be provided as an array keyed by slot number. Each slot can be provided as a single * number, which will considered a question ID, or an array with 'filter' and 'randomquestionids' which will be considered * a random question. * * For example, * [ * [1] => 123, * [2] => [ * 'filter' => ['name' => 'category', 'values' => ['345']], * 'randomquestionids' => ['101', '102'], * ], * [3] => 124, * ] * Will assert that the quiz contains (or doesn't contain if $expectequal is false): * - A question in slot 1 with ID 123, * - A random question in slot 2 with a filter for category ID 345, which matches questions with IDs 101 and 102, * - A question in slot 3 with ID 124. * * @param int $courseid The ID of the course to pick the quiz from. * @param array $expectedslots The layout of slots to compare to, see above for details of the structure. * @param bool $expectequal If true, assert that each slot is equal to the expected value. Otherwise assert it's not equal. */ public function verify_restored_quiz_layout( int $courseid, array $expectedslots, bool $expectequal ): void { $loader = new random_question_loader(new \qubaid_list([])); $modules = get_fast_modinfo($courseid)->get_instances_of('quiz'); $module = reset($modules); $newslots = \mod_quiz\question\bank\qbank_helper::get_question_structure( $module->instance, $module->context); $this->assertCount(count($expectedslots), $newslots); foreach ($newslots as $number => $slot) { $randomquestionids = null; if (is_array($expectedslots[$number])) { // A random question, compare filters and filtered question ids. $actualcontents = $slot->filtercondition['filter']; $expectedcontents = $expectedslots[$number]['filter']; $randomquestions = $loader->get_filtered_questions($slot->filtercondition['filter']); $randomquestionids = array_keys($randomquestions); sort($randomquestionids); sort($expectedslots[$number]['randomquestionids']); } else if (is_numeric($expectedslots[$number])) { // A normal question, compare IDs. $actualcontents = $slot->questionid; $expectedcontents = $expectedslots[$number]; } else { throw new coding_exception('Slots must either be a number or an array.'); } if ($expectequal) { $this->assertEquals($expectedcontents, $actualcontents); if (!is_null($randomquestionids)) { $this->assertEquals($expectedslots[$number]['randomquestionids'], $randomquestionids); } } else { $this->assertNotEquals($expectedcontents, $actualcontents); if (!is_null($randomquestionids)) { $this->assertNotEquals($expectedslots[$number]['randomquestionids'], $randomquestionids); } } } } /** * Restore a quiz that used questions from a shared question bank on the same course, after the course is deleted. * * The restored quiz should use new copies of the questions. */ public function test_quiz_restore_in_a_different_course_using_question_bank(): void { $this->resetAfterTest(); // Create the test quiz. $qbank = self::getDataGenerator()->create_module('qbank', ['course' => $this->course]); $qbankcontext = \context_module::instance($qbank->cmid); [$quiz, $originalslots, $randomquestions, $quizcontext] = $this->create_quiz_with_questions($qbankcontext->id); // Make the backup. $backupid = $this->backup_quiz($quiz, $this->user); // Delete the current course to make sure there is no data. delete_course($this->course, false); // Check if the questions and associated data are deleted properly. $this->assertEquals(0, count(\mod_quiz\question\bank\qbank_helper::get_question_structure($quiz->id, $quizcontext))); // Restore the course. $newcourse = $this->getDataGenerator()->create_course(); $this->restore_quiz($backupid, $newcourse, $this->user); $this->verify_restored_quiz_layout( courseid: $newcourse->id, expectedslots: [ 1 => $originalslots[1]->questionid, 2 => $originalslots[2]->questionid, 3 => [ 'filter' => $originalslots[3]->filtercondition['filter'], 'randomquestionids' => array_keys($randomquestions[3]), ], ], expectequal: false, ); } /** * Restore a quiz that used questions from a shared question bank on the same course. * * The restored quiz should reference the questions in the original question bank. */ public function test_quiz_restore_in_a_different_course_reference_original_question_bank(): void { $this->resetAfterTest(); // Create the test quiz. $qbank = self::getDataGenerator()->create_module('qbank', ['course' => $this->course]); $qbankcontext = \context_module::instance($qbank->cmid); [$quiz, $originalslots, $randomquestions] = $this->create_quiz_with_questions($qbankcontext->id); // Make the backup. $backupid = $this->backup_quiz($quiz, $this->user); // Restore the course. $newcourse = $this->getDataGenerator()->create_course(); $this->restore_quiz($backupid, $newcourse, $this->user); $this->verify_restored_quiz_layout( courseid: $newcourse->id, expectedslots: [ 1 => $originalslots[1]->questionid, 2 => $originalslots[2]->questionid, 3 => [ 'filter' => $originalslots[3]->filtercondition['filter'], 'randomquestionids' => array_keys($randomquestions[3]), ], ], expectequal: true, ); } /** * Restore a quiz that used questions from a shared question bank on the same course, as a user who cannot access the course. * * The restored quiz should use new copies of the questions. */ public function test_quiz_restore_in_a_different_course_cant_use_original_question_bank(): void { $this->resetAfterTest(); // Create the test quiz. $qbank = self::getDataGenerator()->create_module('qbank', ['course' => $this->course]); $qbankcontext = \context_module::instance($qbank->cmid); [$quiz, $originalslots, $randomquestions] = $this->create_quiz_with_questions($qbankcontext->id); // Make the backup. $backupid = $this->backup_quiz($quiz, $this->user); // Restore the course as a new user without access to the original course. $newcourse = $this->getDataGenerator()->create_course(); $newuser = self::getDataGenerator()->create_user(); self::getDataGenerator()->enrol_user($newuser->id, $newcourse->id, 'manager'); $this->setUser($newuser); $this->restore_quiz($backupid, $newcourse, $newuser); $this->verify_restored_quiz_layout( courseid: $newcourse->id, expectedslots: [ 1 => $originalslots[1]->questionid, 2 => $originalslots[2]->questionid, 3 => [ 'filter' => $originalslots[3]->filtercondition['filter'], 'randomquestionids' => array_keys($randomquestions[3]), ], ], expectequal: false, ); } /** * Restore a quiz that used questions from a shared question bank on another course, after that other course is deleted. * * The restored quiz should use new copies of the questions. */ public function test_quiz_restore_in_a_different_course_question_course_has_been_deleted(): void { $this->resetAfterTest(); // Create the test quiz. $qbankcourse = self::getDataGenerator()->create_course(); $qbank = self::getDataGenerator()->create_module('qbank', ['course' => $qbankcourse]); $qbankcontext = \context_module::instance($qbank->cmid); [$quiz, $originalslots, $randomquestions] = $this->create_quiz_with_questions($qbankcontext->id); // Make the backup. $backupid = $this->backup_quiz($quiz, $this->user); // Delete the qbank course. delete_course($qbankcourse, false); // Restore the course as a new copy. $newcourse = $this->getDataGenerator()->create_course(); $this->restore_quiz($backupid, $newcourse, $this->user); // Verify. $this->verify_restored_quiz_layout( courseid: $newcourse->id, expectedslots: [ 1 => $originalslots[1]->questionid, 2 => $originalslots[2]->questionid, 3 => [ 'filter' => $originalslots[3]->filtercondition['filter'], 'randomquestionids' => array_keys($randomquestions[3]), ], ], expectequal: false, ); } /** * Test a quiz backup and restore in a different course without attempts for quiz question bank. * * @covers \mod_quiz\question\bank\qbank_helper::get_question_structure */ public function test_quiz_restore_in_a_different_course_using_quiz_question_bank(): void { $this->resetAfterTest(); // Create the test quiz. $quiz = $this->create_test_quiz($this->course); // Test for questions from a different context. $quizcontext = \context_module::instance($quiz->cmid); $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); $this->add_two_regular_questions($questiongenerator, $quiz, ['contextid' => $quizcontext->id]); $this->add_one_random_question($questiongenerator, $quiz, ['contextid' => $quizcontext->id]); // Make the backup. $backupid = $this->backup_quiz($quiz, $this->user); // Delete the current course to make sure there is no data. delete_course($this->course, false); // Check if the questions and associated datas are deleted properly. $this->assertEquals(0, count(\mod_quiz\question\bank\qbank_helper::get_question_structure( $quiz->id, $quizcontext))); // Restore the course. $newcourse = $this->getDataGenerator()->create_course(); $this->restore_quiz($backupid, $newcourse, $this->user); // Verify. $modules = get_fast_modinfo($newcourse->id)->get_instances_of('quiz'); $module = reset($modules); $this->assertEquals(3, count(\mod_quiz\question\bank\qbank_helper::get_question_structure( $module->instance, $module->context))); } /** * Count the questions for the context. * * @param int $contextid * @param string $extracondition * @return int the number of questions. */ protected function question_count(int $contextid, string $extracondition = ''): int { global $DB; return $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 = ? $extracondition", [$contextid]); } /** * Duplicate a quiz that uses questions from a shared bank on the same course. * * The new quiz should reference the same questions. */ public function test_quiz_duplicate_does_not_duplicate_questions_from_shared_banks(): void { $this->resetAfterTest(); // Test for questions from a qbank context. $qbankcourse = self::getDataGenerator()->create_course(); $qbank = self::getDataGenerator()->create_module('qbank', ['course' => $qbankcourse]); $context = \context_module::instance($qbank->cmid); [$quiz, $originalslots, $randomquestions] = $this->create_quiz_with_questions($context->id); // Count the questions in qbank context. $this->assertEquals(7, $this->question_count($context->id)); $this->assertCount(3, $originalslots); $newquiz = $this->duplicate_quiz($this->course, $quiz); $this->assertEquals(7, $this->question_count($context->id)); $newquizcontext = \context_module::instance($newquiz->id); // Count the questions in the quiz context. $this->assertEquals(0, $this->question_count($newquizcontext->id)); $this->verify_restored_quiz_layout( courseid: $this->course->id, expectedslots: [ 1 => $originalslots[1]->questionid, 2 => $originalslots[2]->questionid, 3 => [ 'filter' => $originalslots[3]->filtercondition['filter'], 'randomquestionids' => array_keys($randomquestions[3]), ], ], expectequal: true, ); } /** * Test quiz duplicate for quiz question bank. * * @covers ::duplicate_module */ public function test_quiz_duplicate_for_quiz_question_bank_questions(): void { $this->resetAfterTest(); $quiz = $this->create_test_quiz($this->course); // Test for questions from a different context. $context = \context_module::instance($quiz->cmid); $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); $this->add_two_regular_questions($questiongenerator, $quiz, ['contextid' => $context->id]); $this->add_one_random_question($questiongenerator, $quiz, ['contextid' => $context->id]); // Count the questions in course context. $this->assertEquals(7, $this->question_count($context->id)); $newquiz = $this->duplicate_quiz($this->course, $quiz); $this->assertEquals(7, $this->question_count($context->id)); $context = \context_module::instance($newquiz->id); // Count the questions in the quiz context. $this->assertEquals(7, $this->question_count($context->id)); } /** * Test quiz restore with attempts. * * @covers \mod_quiz\question\bank\qbank_helper::get_question_structure */ public function test_quiz_restore_with_attempts(): void { $this->resetAfterTest(); // Create a quiz. $quiz = $this->create_test_quiz($this->course); $quizcontext = \context_module::instance($quiz->cmid); $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); $this->add_two_regular_questions($questiongenerator, $quiz, ['contextid' => $quizcontext->id]); $this->add_one_random_question($questiongenerator, $quiz, ['contextid' => $quizcontext->id]); // Attempt it as a student, and check. /** @var \question_usage_by_activity $quba */ [, $quba] = $this->attempt_quiz($quiz, $this->student); $this->assertEquals(3, $quba->question_count()); $this->assertCount(1, quiz_get_user_attempts($quiz->id, $this->student->id)); // Make the backup. $backupid = $this->backup_quiz($quiz, $this->user); // Delete the current course to make sure there is no data. delete_course($this->course, false); // Restore the backup. $newcourse = $this->getDataGenerator()->create_course(); $this->restore_quiz($backupid, $newcourse, $this->user); // Verify. $modules = get_fast_modinfo($newcourse->id)->get_instances_of('quiz'); $module = reset($modules); $this->assertCount(1, quiz_get_user_attempts($module->instance, $this->student->id)); $this->assertCount(3, \mod_quiz\question\bank\qbank_helper::get_question_structure( $module->instance, $module->context)); } /** * Test pre 4.0 quiz restore for regular questions. * * Also, for efficiency, tests restore of the review options. * * @covers \restore_quiz_activity_structure_step::process_quiz_question_legacy_instance */ public function test_pre_4_quiz_restore_for_regular_questions(): void { global $USER, $DB; $this->resetAfterTest(); $backupid = 'abc'; $backuppath = make_backup_temp_directory($backupid); get_file_packer('application/vnd.moodle.backup')->extract_to_pathname( __DIR__ . "/fixtures/moodle_28_quiz.mbz", $backuppath); // Do the 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); $this->assertTrue($rc->execute_precheck()); $rc->execute_plan(); $rc->destroy(); // Get the information about the resulting course and check that it is set up correctly. $modinfo = get_fast_modinfo($newcourseid); $quiz = array_values($modinfo->get_instances_of('quiz'))[0]; $quizobj = \mod_quiz\quiz_settings::create($quiz->instance); $structure = structure::create_for_quiz($quizobj); // Verify the restored review options setting. $this->assertEquals(display_options::DURING | display_options::IMMEDIATELY_AFTER | display_options::LATER_WHILE_OPEN | display_options::AFTER_CLOSE, $quizobj->get_quiz()->reviewmaxmarks); // Are the correct slots returned? $slots = $structure->get_slots(); $this->assertCount(2, $slots); $quizobj->preload_questions(); $quizobj->load_questions(); $questions = $quizobj->get_questions(); $this->assertCount(2, $questions); // Count the questions in quiz qbank. $this->assertEquals(2, $this->question_count($quizobj->get_context()->id)); } /** * Test pre 4.0 quiz restore for random questions. * * @covers \restore_quiz_activity_structure_step::process_quiz_question_legacy_instance */ public function test_pre_4_quiz_restore_for_random_questions(): void { global $USER, $DB; $this->resetAfterTest(); $backupid = 'abc'; $backuppath = make_backup_temp_directory($backupid); get_file_packer('application/vnd.moodle.backup')->extract_to_pathname( __DIR__ . "/fixtures/random_by_tag_quiz.mbz", $backuppath); // Do the 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(); $results = $rc->get_precheck_results(); // Backup contains categories attached to deprecated contexts so the results should only contain warnings for these. $this->assertCount(2, $results['warnings']); foreach ($results['warnings'] as $warning) { $this->assertStringContainsString('will be created at a question bank module context by restore', $warning); } $this->assertArrayNotHasKey('errors', $results); $rc->execute_plan(); $rc->destroy(); // Get the information about the resulting course and check that it is set up correctly. $modinfo = get_fast_modinfo($newcourseid); $qbanks = $modinfo->get_instances_of('qbank'); $this->assertCount(1, $qbanks); $qbank = reset($qbanks); $this->assertEquals(get_string('systembank', 'question'), $qbank->name); $quiz = array_values($modinfo->get_instances_of('quiz'))[0]; $quizobj = \mod_quiz\quiz_settings::create($quiz->instance); $structure = structure::create_for_quiz($quizobj); // Are the correct slots returned? $slots = $structure->get_slots(); $this->assertCount(1, $slots); $quizobj->preload_questions(); $quizobj->load_questions(); $questions = $quizobj->get_questions(); $this->assertCount(1, $questions); // Count the questions for new course mod_qbank question bank. $this->assertEquals(6, $this->question_count(\context_module::instance($qbank->id)->id)); $this->assertEquals(6, $this->question_count(\context_module::instance($qbank->id)->id, "AND q.qtype <> 'random'")); // Count the questions in quiz qbank. $this->assertEquals(0, $this->question_count($quizobj->get_context()->id)); } /** * Test pre 4.0 quiz restore for random question tags. * * @covers \restore_quiz_activity_structure_step::process_quiz_question_legacy_instance */ public function test_pre_4_quiz_restore_for_random_question_tags(): void { global $USER, $DB; $this->resetAfterTest(); $randomtags = [ '1' => ['first question', 'one', 'number one'], '2' => ['first question', 'one', 'number one'], '3' => ['one', 'number one', 'second question'], ]; $backupid = 'abc'; $backuppath = make_backup_temp_directory($backupid); get_file_packer('application/vnd.moodle.backup')->extract_to_pathname( __DIR__ . "/fixtures/moodle_311_quiz.mbz", $backuppath); // Do the 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); $this->assertTrue($rc->execute_precheck()); $rc->execute_plan(); $rc->destroy(); // Get the information about the resulting course and check that it is set up correctly. $modinfo = get_fast_modinfo($newcourseid); $quiz = array_values($modinfo->get_instances_of('quiz'))[0]; $quizobj = \mod_quiz\quiz_settings::create($quiz->instance); $structure = \mod_quiz\structure::create_for_quiz($quizobj); // Count the questions in quiz qbank. $context = \context_module::instance(get_coursemodule_from_instance("quiz", $quizobj->get_quizid(), $newcourseid)->id); $this->assertEquals(2, $this->question_count($context->id)); // Are the correct slots returned? $slots = $structure->get_slots(); $this->assertCount(3, $slots); // Check if the tags match with the actual restored data. foreach ($slots as $slot) { $setreference = $DB->get_record('question_set_references', ['itemid' => $slot->id, 'component' => 'mod_quiz', 'questionarea' => 'slot']); $filterconditions = json_decode($setreference->filtercondition); $tags = []; foreach ($filterconditions->tags as $tagstring) { $tag = explode(',', $tagstring); $tags[] = $tag[1]; } $this->assertEquals([], array_diff($randomtags[$slot->slot], $tags)); } } /** * Test pre 4.0 quiz restore for random question used on multiple quizzes. * * @covers \restore_quiz_activity_structure_step::process_quiz_question_legacy_instance */ public function test_pre_4_quiz_restore_shared_random_question(): void { global $USER, $DB; $this->resetAfterTest(); $backupid = 'abc'; $backuppath = make_backup_temp_directory($backupid); get_file_packer('application/vnd.moodle.backup')->extract_to_pathname( __DIR__ . "/fixtures/pre-40-shared-random-question.mbz", $backuppath); // Do the 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(); $results = $rc->get_precheck_results(); // Backup contains categories attached to deprecated contexts, so we should only have warnings for those. $this->assertCount(1, $results['warnings']); $this->assertStringContainsString('will be created at a question bank module context by restore', $results['warnings'][0]); $this->assertArrayNotHasKey('errors', $results); $rc->execute_plan(); $rc->destroy(); // Get the information about the resulting course and check that it is set up correctly. // Each quiz should contain an instance of the random question. $modinfo = get_fast_modinfo($newcourseid); $qbank = array_values($modinfo->get_instances_of('qbank'))[0]; $quizzes = $modinfo->get_instances_of('quiz'); $this->assertCount(2, $quizzes); foreach ($quizzes as $quiz) { $quizobj = \mod_quiz\quiz_settings::create($quiz->instance); $structure = structure::create_for_quiz($quizobj); // Are the correct slots returned? $slots = $structure->get_slots(); $this->assertCount(1, $slots); $quizobj->preload_questions(); $quizobj->load_questions(); $questions = $quizobj->get_questions(); $this->assertCount(1, $questions); } // Count the questions for new course mod_qbank question bank. // We should have a single question, the random question should have been deleted after the restore. $this->assertEquals(1, $this->question_count(\context_module::instance($qbank->id)->id)); $this->assertEquals(1, $this->question_count(\context_module::instance($qbank->id)->id, "AND q.qtype <> 'random'")); // Count the questions in quiz qbank. $this->assertEquals(0, $this->question_count($quizobj->get_context()->id)); } /** * Ensure that question slots are correctly backed up and restored with all properties. * * @covers \backup_quiz_activity_structure_step::define_structure() * @return void */ public function test_backup_restore_question_slots(): void { $this->resetAfterTest(true); $course1 = $this->getDataGenerator()->create_course(); $course2 = $this->getDataGenerator()->create_course(); $user1 = $this->getDataGenerator()->create_and_enrol($course1, 'editingteacher'); $this->getDataGenerator()->enrol_user($user1->id, $course2->id, 'editingteacher'); // Make a quiz. $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz'); $quiz = $quizgenerator->create_instance(['course' => $course1->id, 'questionsperpage' => 0, 'grade' => 100.0, 'sumgrades' => 3]); // Create some fixed and random questions. $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); $cat = $questiongenerator->create_question_category(); $saq = $questiongenerator->create_question('shortanswer', null, ['category' => $cat->id]); $numq = $questiongenerator->create_question('numerical', null, ['category' => $cat->id]); $matchq = $questiongenerator->create_question('match', null, ['category' => $cat->id]); $randomcat = $questiongenerator->create_question_category(); $questiongenerator->create_question('shortanswer', null, ['category' => $randomcat->id]); $questiongenerator->create_question('numerical', null, ['category' => $randomcat->id]); $questiongenerator->create_question('match', null, ['category' => $randomcat->id]); // Add them to the quiz. quiz_add_quiz_question($saq->id, $quiz, 1, 3); quiz_add_quiz_question($numq->id, $quiz, 2, 2); quiz_add_quiz_question($matchq->id, $quiz, 3, 1); $this->add_random_questions($quiz->id, 3, $randomcat->id, 2); $quizobj = quiz_settings::create($quiz->id, $user1->id); $originalstructure = \mod_quiz\structure::create_for_quiz($quizobj); // Set one slot to a non-default display number. $originalslots = $originalstructure->get_slots(); $firstslot = reset($originalslots); $originalstructure->update_slot_display_number($firstslot->id, rand(5, 10)); // Set one slot to requireprevious. $lastslot = end($originalslots); $originalstructure->update_question_dependency($lastslot->id, true); // Backup and restore the quiz. $backupid = $this->backup_quiz($quiz, $user1); $this->restore_quiz($backupid, $course2, $user1); // Ensure the restored slots match the original slots. $modinfo = get_fast_modinfo($course2); $quizzes = $modinfo->get_instances_of('quiz'); $restoredquiz = reset($quizzes); $restoredquizobj = quiz_settings::create($restoredquiz->instance, $user1->id); $restoredstructure = \mod_quiz\structure::create_for_quiz($restoredquizobj); $restoredslots = array_values($restoredstructure->get_slots()); $originalstructure = \mod_quiz\structure::create_for_quiz($quizobj); $originalslots = array_values($originalstructure->get_slots()); foreach ($restoredslots as $key => $restoredslot) { $originalslot = $originalslots[$key]; $this->assertEquals($originalslot->quizid, $quiz->id); $this->assertEquals($restoredslot->quizid, $restoredquiz->instance); $this->assertEquals($originalslot->slot, $restoredslot->slot); $this->assertEquals($originalslot->page, $restoredslot->page); $this->assertEquals($originalslot->displaynumber, $restoredslot->displaynumber); $this->assertEquals($originalslot->requireprevious, $restoredslot->requireprevious); $this->assertEquals($originalslot->maxmark, $restoredslot->maxmark); } } /** * Test pre 4.3 quiz restore for random question filter conditions. * * @covers \restore_question_set_reference_data_trait::process_question_set_reference */ public function test_pre_43_quiz_restore_for_random_question_filtercondition(): void { global $USER, $DB; $this->resetAfterTest(); $backupid = 'abc'; $backuppath = make_backup_temp_directory($backupid); get_file_packer('application/vnd.moodle.backup')->extract_to_pathname( __DIR__ . "/fixtures/moodle_42_random_question.mbz", $backuppath); // Do the 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); $this->assertTrue($rc->execute_precheck()); $rc->execute_plan(); $rc->destroy(); // Get the information about the resulting course and check that it is set up correctly. $modinfo = get_fast_modinfo($newcourseid); $quiz = array_values($modinfo->get_instances_of('quiz'))[0]; $quizobj = \mod_quiz\quiz_settings::create($quiz->instance); $structure = \mod_quiz\structure::create_for_quiz($quizobj); // Count the questions in quiz qbank. $context = \context_module::instance(get_coursemodule_from_instance("quiz", $quizobj->get_quizid(), $newcourseid)->id); $this->assertEquals(2, $this->question_count($context->id)); // Are the correct slots returned? $slots = $structure->get_slots(); $this->assertCount(1, $slots); // Check that the filtercondition now matches the 4.3 structure. foreach ($slots as $slot) { $setreference = $DB->get_record('question_set_references', ['itemid' => $slot->id, 'component' => 'mod_quiz', 'questionarea' => 'slot']); $filterconditions = json_decode($setreference->filtercondition, true); $this->assertArrayHasKey('cat', $filterconditions); $this->assertArrayHasKey('jointype', $filterconditions); $this->assertArrayHasKey('qpage', $filterconditions); $this->assertArrayHasKey('qperpage', $filterconditions); $this->assertArrayHasKey('filter', $filterconditions); $this->assertArrayHasKey('category', $filterconditions['filter']); $this->assertArrayHasKey('qtagids', $filterconditions['filter']); $this->assertArrayHasKey('filteroptions', $filterconditions['filter']['category']); $this->assertArrayHasKey('includesubcategories', $filterconditions['filter']['category']['filteroptions']); // MDL-79708: Bad filter conversion check. $this->assertArrayNotHasKey('includesubcategories', $filterconditions['filter']['category']); $this->assertArrayNotHasKey('questioncategoryid', $filterconditions); $this->assertArrayNotHasKey('tags', $filterconditions); $expectedtags = \core_tag_tag::get_by_name_bulk(1, ['foo', 'bar']); $expectedtagids = array_values(array_map(fn($expectedtag) => $expectedtag->id, $expectedtags)); $this->assertEquals($expectedtagids, $filterconditions['filter']['qtagids']['values']); $expectedcategory = $DB->get_record('question_categories', ['idnumber' => 'RAND']); $this->assertEquals($expectedcategory->id, $filterconditions['filter']['category']['values'][0]); $expectedcat = implode(',', [$expectedcategory->id, $expectedcategory->contextid]); $this->assertEquals($expectedcat, $filterconditions['cat']); // MDL-79708: Try to convert already converted filter. $filterconditionsold = $filterconditions; $filterconditions = question_reference_manager::convert_legacy_set_reference_filter_condition($filterconditions); // Check that the filtercondition didn't change. $this->assertEquals($filterconditionsold, $filterconditions); // MDL-79708: Try to convert a filter with previously bad conversion. $filterconditions['filter']['category']['includesubcategories'] = 0; unset($filterconditions['filter']['category']['filteroptions']); $filterconditions = question_reference_manager::convert_legacy_set_reference_filter_condition($filterconditions); $this->assertEquals($filterconditionsold, $filterconditions); } } } PK9B\"5$[J[J&tests/calendar_event_modified_test.phpnu[. namespace mod_quiz; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->dirroot . '/mod/quiz/lib.php'); /** * Unit tests for the calendar event modification callbacks used * for dragging and dropping quiz calendar events in the calendar * UI. * * @package mod_quiz * @category test * @copyright 2017 Ryan Wyllie * @license http://www.gnu.org/copyleft/gpl.html GNU Public License */ final class calendar_event_modified_test extends \advanced_testcase { /** * Create an instance of the quiz activity. * * @param array $properties Properties to set on the activity * @return stdClass Quiz activity instance */ protected function create_quiz_instance(array $properties) { global $DB; $generator = $this->getDataGenerator(); if (empty($properties['course'])) { $course = $generator->create_course(); $courseid = $course->id; } else { $courseid = $properties['course']; } $quizgenerator = $generator->get_plugin_generator('mod_quiz'); $quiz = $quizgenerator->create_instance(array_merge(['course' => $courseid], $properties)); if (isset($properties['timemodified'])) { // The generator overrides the timemodified value to set it as // the current time even if a value is provided so we need to // make sure it's set back to the requested value. $quiz->timemodified = $properties['timemodified']; $DB->update_record('quiz', $quiz); } return $quiz; } /** * Create a calendar event for a quiz activity instance. * * @param \stdClass $quiz The activity instance * @param array $eventproperties Properties to set on the calendar event * @return calendar_event */ protected function create_quiz_calendar_event(\stdClass $quiz, array $eventproperties) { $defaultproperties = [ 'name' => 'Test event', 'description' => '', 'format' => 1, 'courseid' => $quiz->course, 'groupid' => 0, 'userid' => 2, 'modulename' => 'quiz', 'instance' => $quiz->id, 'eventtype' => QUIZ_EVENT_TYPE_OPEN, 'timestart' => time(), 'timeduration' => 86400, 'visible' => 1 ]; return new \calendar_event(array_merge($defaultproperties, $eventproperties)); } /** * An unkown event type should not change the quiz instance. */ public function test_mod_quiz_core_calendar_event_timestart_updated_unknown_event(): void { global $DB; $this->resetAfterTest(true); $this->setAdminUser(); $timeopen = time(); $timeclose = $timeopen + DAYSECS; $quiz = $this->create_quiz_instance(['timeopen' => $timeopen, 'timeclose' => $timeclose]); $event = $this->create_quiz_calendar_event($quiz, [ 'eventtype' => QUIZ_EVENT_TYPE_OPEN . "SOMETHING ELSE", 'timestart' => 1 ]); mod_quiz_core_calendar_event_timestart_updated($event, $quiz); $quiz = $DB->get_record('quiz', ['id' => $quiz->id]); $this->assertEquals($timeopen, $quiz->timeopen); $this->assertEquals($timeclose, $quiz->timeclose); } /** * A QUIZ_EVENT_TYPE_OPEN event should update the timeopen property of * the quiz activity. */ public function test_mod_quiz_core_calendar_event_timestart_updated_open_event(): void { global $DB; $this->resetAfterTest(true); $this->setAdminUser(); $timeopen = time(); $timeclose = $timeopen + DAYSECS; $timemodified = 1; $newtimeopen = $timeopen - DAYSECS; $quiz = $this->create_quiz_instance([ 'timeopen' => $timeopen, 'timeclose' => $timeclose, 'timemodified' => $timemodified ]); $event = $this->create_quiz_calendar_event($quiz, [ 'eventtype' => QUIZ_EVENT_TYPE_OPEN, 'timestart' => $newtimeopen ]); mod_quiz_core_calendar_event_timestart_updated($event, $quiz); $quiz = $DB->get_record('quiz', ['id' => $quiz->id]); // Ensure the timeopen property matches the event timestart. $this->assertEquals($newtimeopen, $quiz->timeopen); // Ensure the timeclose isn't changed. $this->assertEquals($timeclose, $quiz->timeclose); // Ensure the timemodified property has been changed. $this->assertNotEquals($timemodified, $quiz->timemodified); } /** * A QUIZ_EVENT_TYPE_CLOSE event should update the timeclose property of * the quiz activity. */ public function test_mod_quiz_core_calendar_event_timestart_updated_close_event(): void { global $DB; $this->resetAfterTest(true); $this->setAdminUser(); $timeopen = time(); $timeclose = $timeopen + DAYSECS; $timemodified = 1; $newtimeclose = $timeclose + DAYSECS; $quiz = $this->create_quiz_instance([ 'timeopen' => $timeopen, 'timeclose' => $timeclose, 'timemodified' => $timemodified ]); $event = $this->create_quiz_calendar_event($quiz, [ 'eventtype' => QUIZ_EVENT_TYPE_CLOSE, 'timestart' => $newtimeclose ]); mod_quiz_core_calendar_event_timestart_updated($event, $quiz); $quiz = $DB->get_record('quiz', ['id' => $quiz->id]); // Ensure the timeclose property matches the event timestart. $this->assertEquals($newtimeclose, $quiz->timeclose); // Ensure the timeopen isn't changed. $this->assertEquals($timeopen, $quiz->timeopen); // Ensure the timemodified property has been changed. $this->assertNotEquals($timemodified, $quiz->timemodified); } /** * A QUIZ_EVENT_TYPE_OPEN event should not update the timeopen property of * the quiz activity if it's an override. */ public function test_mod_quiz_core_calendar_event_timestart_updated_open_event_override(): void { global $DB; $this->resetAfterTest(true); $this->setAdminUser(); $user = $this->getDataGenerator()->create_user(); $timeopen = time(); $timeclose = $timeopen + DAYSECS; $timemodified = 1; $newtimeopen = $timeopen - DAYSECS; $quiz = $this->create_quiz_instance([ 'timeopen' => $timeopen, 'timeclose' => $timeclose, 'timemodified' => $timemodified ]); $event = $this->create_quiz_calendar_event($quiz, [ 'userid' => $user->id, 'eventtype' => QUIZ_EVENT_TYPE_OPEN, 'timestart' => $newtimeopen ]); $record = (object) [ 'quiz' => $quiz->id, 'userid' => $user->id ]; $DB->insert_record('quiz_overrides', $record); mod_quiz_core_calendar_event_timestart_updated($event, $quiz); $quiz = $DB->get_record('quiz', ['id' => $quiz->id]); // Ensure the timeopen property doesn't change. $this->assertEquals($timeopen, $quiz->timeopen); // Ensure the timeclose isn't changed. $this->assertEquals($timeclose, $quiz->timeclose); // Ensure the timemodified property has not been changed. $this->assertEquals($timemodified, $quiz->timemodified); } /** * If a student somehow finds a way to update the quiz calendar event * then the callback should not update the quiz activity otherwise that * would be a security issue. */ public function test_student_role_cant_update_quiz_activity(): void { global $DB; $this->resetAfterTest(); $this->setAdminUser(); $generator = $this->getDataGenerator(); $user = $generator->create_user(); $course = $generator->create_course(); $context = \context_course::instance($course->id); $roleid = $generator->create_role(); $now = time(); $timeopen = (new \DateTime())->setTimestamp($now); $newtimeopen = (new \DateTime())->setTimestamp($now)->modify('+1 day'); $quiz = $this->create_quiz_instance([ 'course' => $course->id, 'timeopen' => $timeopen->getTimestamp() ]); $generator->enrol_user($user->id, $course->id, 'student'); $generator->role_assign($roleid, $user->id, $context->id); $event = $this->create_quiz_calendar_event($quiz, [ 'eventtype' => QUIZ_EVENT_TYPE_OPEN, 'timestart' => $timeopen->getTimestamp() ]); assign_capability('moodle/course:manageactivities', CAP_PROHIBIT, $roleid, $context, true); $this->setUser($user); mod_quiz_core_calendar_event_timestart_updated($event, $quiz); $newquiz = $DB->get_record('quiz', ['id' => $quiz->id]); // The time open shouldn't have changed even though we updated the calendar // event. $this->assertEquals($timeopen->getTimestamp(), $newquiz->timeopen); } /** * A teacher with the capability to modify a quiz module should be * able to update the quiz activity dates by changing the calendar * event. */ public function test_teacher_role_can_update_quiz_activity(): void { global $DB; $this->resetAfterTest(); $this->setAdminUser(); $generator = $this->getDataGenerator(); $user = $generator->create_user(); $course = $generator->create_course(); $context = \context_course::instance($course->id); $roleid = $generator->create_role(); $now = time(); $timeopen = (new \DateTime())->setTimestamp($now); $newtimeopen = (new \DateTime())->setTimestamp($now)->modify('+1 day'); $quiz = $this->create_quiz_instance([ 'course' => $course->id, 'timeopen' => $timeopen->getTimestamp() ]); $generator->enrol_user($user->id, $course->id, 'teacher'); $generator->role_assign($roleid, $user->id, $context->id); $event = $this->create_quiz_calendar_event($quiz, [ 'eventtype' => QUIZ_EVENT_TYPE_OPEN, 'timestart' => $newtimeopen->getTimestamp() ]); assign_capability('moodle/course:manageactivities', CAP_ALLOW, $roleid, $context, true); $this->setUser($user); // Trigger and capture the event. $sink = $this->redirectEvents(); mod_quiz_core_calendar_event_timestart_updated($event, $quiz); $triggeredevents = $sink->get_events(); $moduleupdatedevents = array_filter($triggeredevents, function($e) { return is_a($e, 'core\event\course_module_updated'); }); $newquiz = $DB->get_record('quiz', ['id' => $quiz->id]); // The should be updated along with the event because the user has sufficient // capabilities. $this->assertEquals($newtimeopen->getTimestamp(), $newquiz->timeopen); // Confirm that a module updated event is fired when the module // is changed. $this->assertNotEmpty($moduleupdatedevents); } /** * An unkown event type should not have any limits */ public function test_mod_quiz_core_calendar_get_valid_event_timestart_range_unknown_event(): void { global $DB; $this->resetAfterTest(true); $this->setAdminUser(); $timeopen = time(); $timeclose = $timeopen + DAYSECS; $quiz = $this->create_quiz_instance([ 'timeopen' => $timeopen, 'timeclose' => $timeclose ]); $event = $this->create_quiz_calendar_event($quiz, [ 'eventtype' => QUIZ_EVENT_TYPE_OPEN . "SOMETHING ELSE", 'timestart' => 1 ]); list ($min, $max) = mod_quiz_core_calendar_get_valid_event_timestart_range($event, $quiz); $this->assertNull($min); $this->assertNull($max); } /** * The open event should be limited by the quiz's timeclose property, if it's set. */ public function test_mod_quiz_core_calendar_get_valid_event_timestart_range_open_event(): void { global $DB; $this->resetAfterTest(true); $this->setAdminUser(); $timeopen = time(); $timeclose = $timeopen + DAYSECS; $quiz = $this->create_quiz_instance([ 'timeopen' => $timeopen, 'timeclose' => $timeclose ]); $event = $this->create_quiz_calendar_event($quiz, [ 'eventtype' => QUIZ_EVENT_TYPE_OPEN, 'timestart' => 1 ]); // The max limit should be bounded by the timeclose value. list ($min, $max) = mod_quiz_core_calendar_get_valid_event_timestart_range($event, $quiz); $this->assertNull($min); $this->assertEquals($timeclose, $max[0]); // No timeclose value should result in no upper limit. $quiz->timeclose = 0; list ($min, $max) = mod_quiz_core_calendar_get_valid_event_timestart_range($event, $quiz); $this->assertNull($min); $this->assertNull($max); } /** * An override event should not have any limits. */ public function test_mod_quiz_core_calendar_get_valid_event_timestart_range_override_event(): void { global $DB; $this->resetAfterTest(true); $this->setAdminUser(); $generator = $this->getDataGenerator(); $user = $generator->create_user(); $course = $generator->create_course(); $timeopen = time(); $timeclose = $timeopen + DAYSECS; $quiz = $this->create_quiz_instance([ 'course' => $course->id, 'timeopen' => $timeopen, 'timeclose' => $timeclose ]); $event = $this->create_quiz_calendar_event($quiz, [ 'userid' => $user->id, 'eventtype' => QUIZ_EVENT_TYPE_OPEN, 'timestart' => 1 ]); $record = (object) [ 'quiz' => $quiz->id, 'userid' => $user->id ]; $DB->insert_record('quiz_overrides', $record); list ($min, $max) = mod_quiz_core_calendar_get_valid_event_timestart_range($event, $quiz); $this->assertFalse($min); $this->assertFalse($max); } /** * The close event should be limited by the quiz's timeopen property, if it's set. */ public function test_mod_quiz_core_calendar_get_valid_event_timestart_range_close_event(): void { global $DB; $this->resetAfterTest(true); $this->setAdminUser(); $timeopen = time(); $timeclose = $timeopen + DAYSECS; $quiz = $this->create_quiz_instance([ 'timeopen' => $timeopen, 'timeclose' => $timeclose ]); $event = $this->create_quiz_calendar_event($quiz, [ 'eventtype' => QUIZ_EVENT_TYPE_CLOSE, 'timestart' => 1, ]); // The max limit should be bounded by the timeclose value. list ($min, $max) = mod_quiz_core_calendar_get_valid_event_timestart_range($event, $quiz); $this->assertEquals($timeopen, $min[0]); $this->assertNull($max); // No timeclose value should result in no upper limit. $quiz->timeopen = 0; list ($min, $max) = mod_quiz_core_calendar_get_valid_event_timestart_range($event, $quiz); $this->assertNull($min); $this->assertNull($max); } /** * When the close date event is changed and it results in the time close value of * the quiz being updated then the open quiz attempts should also be updated. */ public function test_core_calendar_event_timestart_updated_update_quiz_attempt(): void { global $DB; $this->resetAfterTest(); $this->setAdminUser(); $generator = $this->getDataGenerator(); $teacher = $generator->create_user(); $student = $generator->create_user(); $course = $generator->create_course(); $context = \context_course::instance($course->id); $roleid = $generator->create_role(); $now = time(); $timelimit = 600; $timeopen = (new \DateTime())->setTimestamp($now); $timeclose = (new \DateTime())->setTimestamp($now)->modify('+1 day'); // The new close time being earlier than the time open + time limit should // result in an update to the quiz attempts. $newtimeclose = $timeopen->getTimestamp() + $timelimit - 10; $quiz = $this->create_quiz_instance([ 'course' => $course->id, 'timeopen' => $timeopen->getTimestamp(), 'timeclose' => $timeclose->getTimestamp(), 'timelimit' => $timelimit ]); $generator->enrol_user($student->id, $course->id, 'student'); $generator->enrol_user($teacher->id, $course->id, 'teacher'); $generator->role_assign($roleid, $teacher->id, $context->id); $event = $this->create_quiz_calendar_event($quiz, [ 'eventtype' => QUIZ_EVENT_TYPE_CLOSE, 'timestart' => $newtimeclose ]); assign_capability('moodle/course:manageactivities', CAP_ALLOW, $roleid, $context, true); $attemptid = $DB->insert_record( 'quiz_attempts', [ 'quiz' => $quiz->id, 'userid' => $student->id, 'state' => 'inprogress', 'timestart' => $timeopen->getTimestamp(), 'timecheckstate' => 0, 'layout' => '', 'uniqueid' => 1 ] ); $this->setUser($teacher); mod_quiz_core_calendar_event_timestart_updated($event, $quiz); $quiz = $DB->get_record('quiz', ['id' => $quiz->id]); $attempt = $DB->get_record('quiz_attempts', ['id' => $attemptid]); // When the close date is changed so that it's earlier than the time open // plus the time limit of the quiz then the attempt's timecheckstate should // be updated to the new time close date of the quiz. $this->assertEquals($newtimeclose, $attempt->timecheckstate); $this->assertEquals($newtimeclose, $quiz->timeclose); } } PK9B\4;:U:U tests/custom_completion_test.phpnu[. declare(strict_types=1); namespace mod_quiz; use advanced_testcase; use cm_info; use core_completion\cm_completion_details; use grade_item; use mod_quiz\completion\custom_completion; use question_engine; use mod_quiz\quiz_settings; use stdClass; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->libdir . '/completionlib.php'); /** * Class for unit testing mod_quiz/custom_completion. * * @package mod_quiz * @copyright 2021 Shamim Rezaie * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @coversDefaultClass \mod_quiz\completion\custom_completion */ final class custom_completion_test extends advanced_testcase { /** * Setup function for all tests. * * @param array $completionoptions ['nbstudents'] => int, ['qtype'] => string, ['quizoptions'] => array * @return array [$students, $quiz, $cm, $litecm] */ private function setup_quiz_for_testing_completion(array $completionoptions): array { global $CFG, $DB; $this->resetAfterTest(true); // Enable completion before creating modules, otherwise the completion data is not written in DB. $CFG->enablecompletion = true; // Create a course and students. $studentrole = $DB->get_record('role', ['shortname' => 'student']); $course = $this->getDataGenerator()->create_course(['enablecompletion' => true]); $students = []; $sumgrades = $completionoptions['sumgrades'] ?? 1; $nbquestions = $completionoptions['nbquestions'] ?? 1; for ($i = 0; $i < $completionoptions['nbstudents']; $i++) { $students[$i] = $this->getDataGenerator()->create_user(); $this->assertTrue($this->getDataGenerator()->enrol_user($students[$i]->id, $course->id, $studentrole->id)); } // Make a quiz. $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz'); $data = array_merge([ 'course' => $course->id, 'grade' => 100.0, 'questionsperpage' => 0, 'sumgrades' => $sumgrades, 'completion' => COMPLETION_TRACKING_AUTOMATIC ], $completionoptions['quizoptions']); $quiz = $quizgenerator->create_instance($data); $litecm = get_coursemodule_from_id('quiz', $quiz->cmid); $cm = cm_info::create($litecm); // Create a question. $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); $cat = $questiongenerator->create_question_category(); for ($i = 0; $i < $nbquestions; $i++) { $overrideparams = ['category' => $cat->id]; if (isset($completionoptions['questiondefaultmarks'][$i])) { $overrideparams['defaultmark'] = $completionoptions['questiondefaultmarks'][$i]; } $question = $questiongenerator->create_question($completionoptions['qtype'], null, $overrideparams); quiz_add_quiz_question($question->id, $quiz); } // Set grade to pass. $item = grade_item::fetch(['courseid' => $course->id, 'itemtype' => 'mod', 'itemmodule' => 'quiz', 'iteminstance' => $quiz->id, 'outcomeid' => null]); $item->gradepass = 80; $item->update(); return [ $students, $quiz, $cm, $litecm ]; } /** * Helper function for tests. * Starts an attempt, processes responses and finishes the attempt. * * @param array $attemptoptions ['quiz'] => object, ['student'] => object, ['tosubmit'] => array, ['attemptnumber'] => int */ private function do_attempt_quiz(array $attemptoptions) { $quizobj = quiz_settings::create((int) $attemptoptions['quiz']->id); // Start the passing attempt. $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, $attemptoptions['attemptnumber'], false, $timenow, false, $attemptoptions['student']->id); quiz_start_new_attempt($quizobj, $quba, $attempt, $attemptoptions['attemptnumber'], $timenow); quiz_attempt_save_started($quizobj, $quba, $attempt); // Process responses from the student. $attemptobj = quiz_attempt::create($attempt->id); $attemptobj->process_submitted_actions($timenow, false, $attemptoptions['tosubmit']); // Finish the attempt. $attemptobj = quiz_attempt::create($attempt->id); $this->assertTrue($attemptobj->has_response_to_at_least_one_graded_question()); $attemptobj->process_submit($timenow, false); $attemptobj->process_grade_submission($timenow); } /** * Test checking the completion state of a quiz base on core's completionpassgrade criteria. * The quiz requires a passing grade to be completed. */ public function test_completionpass(): void { list($students, $quiz, $cm) = $this->setup_quiz_for_testing_completion([ 'nbstudents' => 2, 'qtype' => 'numerical', 'quizoptions' => [ 'completionusegrade' => 1, 'completionpassgrade' => 1 ] ]); list($passstudent, $failstudent) = $students; // Do a passing attempt. $this->do_attempt_quiz([ 'quiz' => $quiz, 'student' => $passstudent, 'attemptnumber' => 1, 'tosubmit' => [1 => ['answer' => '3.14']] ]); $completioninfo = new \completion_info($cm->get_course()); $completiondetails = new cm_completion_details($completioninfo, $cm, (int) $passstudent->id); // Check the results. $this->assertEquals(COMPLETION_COMPLETE_PASS, $completiondetails->get_details()['completionpassgrade']->status); $this->assertEquals( 'Receive a passing grade', $completiondetails->get_details()['completionpassgrade']->description ); // Do a failing attempt. $this->do_attempt_quiz([ 'quiz' => $quiz, 'student' => $failstudent, 'attemptnumber' => 1, 'tosubmit' => [1 => ['answer' => '0']] ]); $completiondetails = new cm_completion_details($completioninfo, $cm, (int) $failstudent->id); // Check the results. $this->assertEquals(COMPLETION_COMPLETE_FAIL, $completiondetails->get_details()['completionpassgrade']->status); $this->assertEquals( 'Receive a passing grade', $completiondetails->get_details()['completionpassgrade']->description ); } /** * Test checking the completion state of a quiz. * To be completed, this quiz requires either a passing grade or for all attempts to be used up. * * @covers ::get_state * @covers ::get_custom_rule_descriptions */ public function test_completionexhausted(): void { list($students, $quiz, $cm) = $this->setup_quiz_for_testing_completion([ 'nbstudents' => 2, 'qtype' => 'numerical', 'quizoptions' => [ 'attempts' => 2, 'completionusegrade' => 1, 'completionpassgrade' => 1, 'completionattemptsexhausted' => 1 ] ]); list($passstudent, $exhauststudent) = $students; // Start a passing attempt. $this->do_attempt_quiz([ 'quiz' => $quiz, 'student' => $passstudent, 'attemptnumber' => 1, 'tosubmit' => [1 => ['answer' => '3.14']] ]); $completioninfo = new \completion_info($cm->get_course()); // Check the results. Quiz is completed by $passstudent because of passing grade. $studentid = (int) $passstudent->id; $customcompletion = new custom_completion($cm, $studentid, $completioninfo->get_core_completion_state($cm, $studentid)); $this->assertArrayHasKey('completionpassorattemptsexhausted', $cm->customdata['customcompletionrules']); $this->assertEquals(COMPLETION_COMPLETE, $customcompletion->get_state('completionpassorattemptsexhausted')); $this->assertEquals( 'Receive a pass grade or complete all available attempts', $customcompletion->get_custom_rule_descriptions()['completionpassorattemptsexhausted'] ); // Do a failing attempt. $this->do_attempt_quiz([ 'quiz' => $quiz, 'student' => $exhauststudent, 'attemptnumber' => 1, 'tosubmit' => [1 => ['answer' => '0']] ]); // Check the results. Quiz is not completed by $exhauststudent yet because of failing grade and of remaining attempts. $studentid = (int) $exhauststudent->id; $customcompletion = new custom_completion($cm, $studentid, $completioninfo->get_core_completion_state($cm, $studentid)); $this->assertArrayHasKey('completionpassorattemptsexhausted', $cm->customdata['customcompletionrules']); $this->assertEquals(COMPLETION_INCOMPLETE, $customcompletion->get_state('completionpassorattemptsexhausted')); $this->assertEquals( 'Receive a pass grade or complete all available attempts', $customcompletion->get_custom_rule_descriptions()['completionpassorattemptsexhausted'] ); // Do a second failing attempt. $this->do_attempt_quiz([ 'quiz' => $quiz, 'student' => $exhauststudent, 'attemptnumber' => 2, 'tosubmit' => [1 => ['answer' => '0']] ]); // Check the results. Quiz is completed by $exhauststudent because there are no remaining attempts. $customcompletion = new custom_completion($cm, $studentid, $completioninfo->get_core_completion_state($cm, $studentid)); $this->assertArrayHasKey('completionpassorattemptsexhausted', $cm->customdata['customcompletionrules']); $this->assertEquals(COMPLETION_COMPLETE, $customcompletion->get_state('completionpassorattemptsexhausted')); $this->assertEquals( 'Receive a pass grade or complete all available attempts', $customcompletion->get_custom_rule_descriptions()['completionpassorattemptsexhausted'] ); } /** * Test checking the completion state of a quiz. * To be completed, this quiz requires a minimum number of attempts. * * @covers ::get_state * @covers ::get_custom_rule_descriptions */ public function test_completionminattempts(): void { list($students, $quiz, $cm) = $this->setup_quiz_for_testing_completion([ 'nbstudents' => 1, 'qtype' => 'essay', 'quizoptions' => [ 'completionminattemptsenabled' => 1, 'completionminattempts' => 2 ] ]); list($student) = $students; // Do a first attempt. $this->do_attempt_quiz([ 'quiz' => $quiz, 'student' => $student, 'attemptnumber' => 1, 'tosubmit' => [1 => ['answer' => 'Lorem ipsum.', 'answerformat' => '1']] ]); // Check the results. Quiz is not completed yet because only one attempt was done. $customcompletion = new custom_completion($cm, (int) $student->id); $this->assertArrayHasKey('completionminattempts', $cm->customdata['customcompletionrules']); $this->assertEquals(COMPLETION_INCOMPLETE, $customcompletion->get_state('completionminattempts')); $this->assertEquals( 'Make attempts: 2', $customcompletion->get_custom_rule_descriptions()['completionminattempts'] ); // Do a second attempt. $this->do_attempt_quiz([ 'quiz' => $quiz, 'student' => $student, 'attemptnumber' => 2, 'tosubmit' => [1 => ['answer' => 'Lorem ipsum.', 'answerformat' => '1']] ]); // Check the results. Quiz is completed by $student because two attempts were done. $customcompletion = new custom_completion($cm, (int) $student->id); $this->assertArrayHasKey('completionminattempts', $cm->customdata['customcompletionrules']); $this->assertEquals(COMPLETION_COMPLETE, $customcompletion->get_state('completionminattempts')); $this->assertEquals( 'Make attempts: 2', $customcompletion->get_custom_rule_descriptions()['completionminattempts'] ); } /** * Test for get_defined_custom_rules(). * * @covers ::get_defined_custom_rules */ public function test_get_defined_custom_rules(): void { $rules = custom_completion::get_defined_custom_rules(); $this->assertCount(2, $rules); $this->assertEquals( ['completionpassorattemptsexhausted', 'completionminattempts'], $rules ); } /** * Test update moduleinfo. * * @covers \update_moduleinfo */ public function test_update_moduleinfo(): void { $this->setAdminUser(); // We need lite cm object not a full cm because update_moduleinfo is not allow some properties to be updated. list($students, $quiz, $cm, $litecm) = $this->setup_quiz_for_testing_completion([ 'nbstudents' => 1, 'qtype' => 'numerical', 'nbquestions' => 2, 'sumgrades' => 100, 'questiondefaultmarks' => [20, 80], 'quizoptions' => [ 'completionusegrade' => 1, 'completionpassgrade' => 1, 'completionview' => 0, ] ]); $course = $cm->get_course(); list($student) = $students; // Do a first attempt with a pass marks = 20. $this->do_attempt_quiz([ 'quiz' => $quiz, 'student' => $student, 'attemptnumber' => 1, 'tosubmit' => [1 => ['answer' => '3.14']] ]); $completioninfo = new \completion_info($course); $cminfo = \cm_info::create($cm); $completiondetails = new cm_completion_details($completioninfo, $cminfo, (int) $student->id); // Check the results. Completion is fail because gradepass = 80. $this->assertEquals(COMPLETION_COMPLETE_FAIL, $completiondetails->get_details()['completionpassgrade']->status); $this->assertEquals( 'Receive a passing grade', $completiondetails->get_details()['completionpassgrade']->description ); // Update quiz with passgrade = 20 and use highest grade to calculate. $moduleinfo = $this->prepare_module_info($cm, $quiz, $course, 20, QUIZ_GRADEHIGHEST); update_moduleinfo($litecm, $moduleinfo, $course, null); $completiondetails = new cm_completion_details($completioninfo, $cminfo, (int) $student->id); // Check the results. Completion is pass. $this->assertEquals(COMPLETION_COMPLETE_PASS, $completiondetails->get_details()['completionpassgrade']->status); $this->assertEquals( 'Receive a passing grade', $completiondetails->get_details()['completionpassgrade']->description ); // Do a second attempt with pass marks = 80. $this->do_attempt_quiz([ 'quiz' => $quiz, 'student' => $student, 'attemptnumber' => 2, 'tosubmit' => [2 => ['answer' => '3.14']] ]); // Update quiz with gradepass = 80 and use highest grade to calculate completion. $moduleinfo = $this->prepare_module_info($cm, $quiz, $course, 80, QUIZ_GRADEHIGHEST); update_moduleinfo($litecm, $moduleinfo, $course, null); $completiondetails = new cm_completion_details($completioninfo, $cminfo, (int) $student->id); // Check the results. Completion is pass. $this->assertEquals(COMPLETION_COMPLETE_PASS, $completiondetails->get_details()['completionpassgrade']->status); $this->assertEquals( 'Receive a passing grade', $completiondetails->get_details()['completionpassgrade']->description ); // Update quiz with gradepass = 80 and use average grade to calculate completion. $moduleinfo = $this->prepare_module_info($cm, $quiz, $course, 80, QUIZ_GRADEAVERAGE); update_moduleinfo($litecm, $moduleinfo, $course, null); $completiondetails = new cm_completion_details($completioninfo, $cminfo, (int) $student->id); // Check the results. Completion is fail because student grade = 50. $this->assertEquals(COMPLETION_COMPLETE_FAIL, $completiondetails->get_details()['completionpassgrade']->status); $this->assertEquals( 'Receive a passing grade', $completiondetails->get_details()['completionpassgrade']->description ); // Update quiz with gradepass = 50 and use average grade to calculate completion. $moduleinfo = $this->prepare_module_info($cm, $quiz, $course, 50, QUIZ_GRADEAVERAGE); update_moduleinfo($litecm, $moduleinfo, $course, null); $completiondetails = new cm_completion_details($completioninfo, $cminfo, (int) $student->id); // Check the results. Completion is pass. $this->assertEquals(COMPLETION_COMPLETE_PASS, $completiondetails->get_details()['completionpassgrade']->status); $this->assertEquals( 'Receive a passing grade', $completiondetails->get_details()['completionpassgrade']->description ); // Update quiz with gradepass = 50 and use first attempt grade to calculate completion. $moduleinfo = $this->prepare_module_info($cm, $quiz, $course, 50, QUIZ_ATTEMPTFIRST); update_moduleinfo($litecm, $moduleinfo, $course, null); $completiondetails = new cm_completion_details($completioninfo, $cminfo, (int) $student->id); // Check the results. Completion is fail. $this->assertEquals(COMPLETION_COMPLETE_FAIL, $completiondetails->get_details()['completionpassgrade']->status); $this->assertEquals( 'Receive a passing grade', $completiondetails->get_details()['completionpassgrade']->description ); // Update quiz with gradepass = 50 and use last attempt grade to calculate completion. $moduleinfo = $this->prepare_module_info($cm, $quiz, $course, 50, QUIZ_ATTEMPTLAST); update_moduleinfo($litecm, $moduleinfo, $course, null); $completiondetails = new cm_completion_details($completioninfo, $cminfo, (int) $student->id); // Check the results. Completion is fail. $this->assertEquals(COMPLETION_COMPLETE_PASS, $completiondetails->get_details()['completionpassgrade']->status); $this->assertEquals( 'Receive a passing grade', $completiondetails->get_details()['completionpassgrade']->description ); } /** * Set up moduleinfo object sample data for quiz instance. * * @param cm_info $cm course-module instance * @param stdClass $quiz quiz instance data. * @param stdClass $course Course related data. * @param int $gradepass Grade to pass and completed completion. * @param string $grademethod grade attempt method. * @return stdClass */ private function prepare_module_info(cm_info $cm, stdClass $quiz, stdClass $course, int $gradepass, string $grademethod): \stdClass { $grouping = $this->getDataGenerator()->create_grouping(['courseid' => $course->id]); // Module test values. $moduleinfo = new \stdClass(); $moduleinfo->coursemodule = $cm->id; $moduleinfo->section = 1; $moduleinfo->course = $course->id; $moduleinfo->groupingid = $grouping->id; $draftideditor = 0; file_prepare_draft_area($draftideditor, null, null, null, null); $moduleinfo->introeditor = ['text' => 'This is a module', 'format' => FORMAT_HTML, 'itemid' => $draftideditor]; $moduleinfo->modulename = 'quiz'; $moduleinfo->quizpassword = ''; $moduleinfo->cmidnumber = ''; $moduleinfo->maxmarksopen = 1; $moduleinfo->marksopen = 1; $moduleinfo->visible = 1; $moduleinfo->visibleoncoursepage = 1; $moduleinfo->completion = COMPLETION_TRACKING_AUTOMATIC; $moduleinfo->completionview = COMPLETION_VIEW_NOT_REQUIRED; $moduleinfo->name = $quiz->name; $moduleinfo->timeopen = $quiz->timeopen; $moduleinfo->timeclose = $quiz->timeclose; $moduleinfo->timelimit = $quiz->timelimit; $moduleinfo->graceperiod = $quiz->graceperiod; $moduleinfo->decimalpoints = $quiz->decimalpoints; $moduleinfo->questiondecimalpoints = $quiz->questiondecimalpoints; $moduleinfo->gradepass = $gradepass; $moduleinfo->grademethod = $grademethod; return $moduleinfo; } } PK9B\PRtests/tags_test.phpnu[. namespace mod_quiz; use mod_quiz\question\bank\qbank_helper; use mod_quiz\quiz_settings; /** * Test the restore of random question tags. * * @package mod_quiz * @category test * @copyright 2018 Shamim Rezaie * @author 2021 Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class tags_test extends \advanced_testcase { public function test_restore_random_question_by_tag(): void { global $CFG, $USER, $DB; require_once($CFG->dirroot . '/backup/util/includes/restore_includes.php'); require_once($CFG->dirroot . '/mod/quiz/locallib.php'); $this->resetAfterTest(); $this->setAdminUser(); $backupid = 'abc'; $backuppath = make_backup_temp_directory($backupid); get_file_packer('application/vnd.moodle.backup')->extract_to_pathname( __DIR__ . "/fixtures/random_by_tag_quiz.mbz", $backuppath); // Do the 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(); $results = $rc->get_precheck_results(); // Backup contains categories attached to deprecated contexts so the results should only contain warnings for these. $this->assertCount(2, $results['warnings']); foreach ($results['warnings'] as $warning) { $this->assertStringContainsString('will be created at a question bank module context by restore', $warning); } $this->assertArrayNotHasKey('errors', $results); $rc->execute_plan(); $rc->destroy(); // Get the information about the resulting course and check that it is set up correctly. $modinfo = get_fast_modinfo($newcourseid); $quiz = array_values($modinfo->get_instances_of('quiz'))[0]; $quizobj = quiz_settings::create($quiz->instance); $structure = structure::create_for_quiz($quizobj); // Are the correct slots returned? $slots = $structure->get_slots(); $this->assertCount(1, $slots); $quizobj->preload_questions(); $quizobj->load_questions(); $questions = $quizobj->get_questions(); $this->assertCount(1, $questions); $question = array_values($questions)[0]; $tag1 = \core_tag_tag::get_by_name(0, 't1', 'id, name'); $this->assertNotFalse($tag1); $tag2 = \core_tag_tag::get_by_name(0, 't2', 'id, name'); $this->assertNotFalse($tag2); $tag3 = \core_tag_tag::get_by_name(0, 't3', 'id, name'); $this->assertNotFalse($tag3); $slottags = $this->get_tags_for_slot($question->slotid); $slottags = reset($slottags); $slottags = explode(',', $slottags); $this->assertEquals("{$tag2->id},{$tag2->name}", "{$slottags[0]},{$slottags[1]}"); // Course context question cats get restored to a default qbank module instance. $modinfo = get_fast_modinfo($newcourseid); $qbanks = $modinfo->get_instances_of('qbank'); $this->assertCount(1, $qbanks); $qbank = reset($qbanks); $defaultcategory = question_get_default_category(\context_module::instance($qbank->id)->id, true); $this->assertEquals($defaultcategory->id, $question->category); $randomincludingsubcategories = $DB->get_record('question_set_references', ['itemid' => reset($slots)->id, 'component' => 'mod_quiz', 'questionarea' => 'slot']); $filtercondition = json_decode($randomincludingsubcategories->filtercondition); $this->assertEquals(0, $filtercondition->includingsubcategories); } /** * Helper to get the random tags, if any, for a slot. * * @param int $slotid the id of the slot to get tags for. * @return array the tags. */ protected function get_tags_for_slot(int $slotid): array { global $DB; $referencedata = $DB->get_record('question_set_references', ['itemid' => $slotid, 'component' => 'mod_quiz', 'questionarea' => 'slot']); if (isset($referencedata->filtercondition)) { $filtercondition = json_decode($referencedata->filtercondition); if (isset($filtercondition->tags)) { return $filtercondition->tags; } } return []; } } PK9B\`H r1tests/output/attempt_summary_information_test.phpnu[. namespace mod_quiz\output; use advanced_testcase; /** * Tests for {@see attempt_summary_information}. * * @package mod_quiz * @copyright The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @covers \mod_quiz\output\attempt_summary_information */ final class attempt_summary_information_test extends advanced_testcase { public function test_add_item(): void { global $PAGE; $summary = new attempt_summary_information(); $summary->add_item('test', 'Test name', 'Test value'); $data = $summary->export_for_template($PAGE->get_renderer('mod_quiz')); $this->assertEquals([(object) ['title' => 'Test name', 'content' => 'Test value']], $data['items']); } public function test_add_item_before_start(): void { global $PAGE; $summary = new attempt_summary_information(); $summary->add_item('test', 'Test name', 'Test value'); $summary->add_item_before('newitem', 'New name', 'New value', 'test'); $data = $summary->export_for_template($PAGE->get_renderer('mod_quiz')); $this->assertEquals( [ (object) ['title' => 'New name', 'content' => 'New value'], (object) ['title' => 'Test name', 'content' => 'Test value'], ], $data['items'], ); } public function test_add_item_before_middle(): void { global $PAGE; $summary = new attempt_summary_information(); $summary->add_item('item1', 'Existing 1', 'One'); $summary->add_item('item2', 'Existing 2', 'Two'); $summary->add_item_before('newitem', 'New name', 'New value', 'item2'); $data = $summary->export_for_template($PAGE->get_renderer('mod_quiz')); $this->assertEquals( [ (object) ['title' => 'Existing 1', 'content' => 'One'], (object) ['title' => 'New name', 'content' => 'New value'], (object) ['title' => 'Existing 2', 'content' => 'Two'], ], $data['items'], ); } public function test_add_item_before_no_match(): void { global $PAGE; $summary = new attempt_summary_information(); $summary->add_item('test', 'Test name', 'Test value'); $summary->add_item_before('newitem', 'New name', 'New value', 'unknown'); $data = $summary->export_for_template($PAGE->get_renderer('mod_quiz')); $this->assertEquals( [ (object) ['title' => 'New name', 'content' => 'New value'], (object) ['title' => 'Test name', 'content' => 'Test value'], ], $data['items'], ); } public function test_add_item_before_empty(): void { global $PAGE; $summary = new attempt_summary_information(); $summary->add_item_before('newitem', 'New name', 'New value', 'unknown'); $data = $summary->export_for_template($PAGE->get_renderer('mod_quiz')); $this->assertEquals([(object) ['title' => 'New name', 'content' => 'New value']], $data['items']); } public function test_add_item_after_end(): void { global $PAGE; $summary = new attempt_summary_information(); $summary->add_item('test', 'Test name', 'Test value'); $summary->add_item_after('newitem', 'New name', 'New value', 'test'); $data = $summary->export_for_template($PAGE->get_renderer('mod_quiz')); $this->assertEquals( [ (object) ['title' => 'Test name', 'content' => 'Test value'], (object) ['title' => 'New name', 'content' => 'New value'], ], $data['items'], ); } public function test_add_item_after_middle(): void { global $PAGE; $summary = new attempt_summary_information(); $summary->add_item('item1', 'Existing 1', 'One'); $summary->add_item('item2', 'Existing 2', 'Two'); $summary->add_item_after('newitem', 'New name', 'New value', 'item1'); $data = $summary->export_for_template($PAGE->get_renderer('mod_quiz')); $this->assertEquals( [ (object) ['title' => 'Existing 1', 'content' => 'One'], (object) ['title' => 'New name', 'content' => 'New value'], (object) ['title' => 'Existing 2', 'content' => 'Two'], ], $data['items'], ); } public function test_add_item_after_no_match(): void { global $PAGE; $summary = new attempt_summary_information(); $summary->add_item('test', 'Test name', 'Test value'); $summary->add_item_after('newitem', 'New name', 'New value', 'unknown'); $data = $summary->export_for_template($PAGE->get_renderer('mod_quiz')); $this->assertEquals( [ (object) ['title' => 'Test name', 'content' => 'Test value'], (object) ['title' => 'New name', 'content' => 'New value'], ], $data['items'], ); } public function test_add_item_after_empty(): void { global $PAGE; $summary = new attempt_summary_information(); $summary->add_item_after('newitem', 'New name', 'New value', 'unknown'); $data = $summary->export_for_template($PAGE->get_renderer('mod_quiz')); $this->assertEquals([(object) ['title' => 'New name', 'content' => 'New value']], $data['items']); } public function test_remove_item(): void { global $PAGE; $summary = new attempt_summary_information(); $summary->add_item('item1', 'Existing 1', 'One'); $summary->add_item('item2', 'Existing 2', 'Two'); $summary->remove_item('item1'); $data = $summary->export_for_template($PAGE->get_renderer('mod_quiz')); $this->assertEquals([(object) ['title' => 'Existing 2', 'content' => 'Two']], $data['items']); } public function test_remove_item_not_present(): void { global $PAGE; $summary = new attempt_summary_information(); $summary->add_item('item1', 'Existing 1', 'One'); $summary->add_item('item2', 'Existing 2', 'Two'); $summary->remove_item('item3'); $data = $summary->export_for_template($PAGE->get_renderer('mod_quiz')); $this->assertEquals( [ (object) ['title' => 'Existing 1', 'content' => 'One'], (object) ['title' => 'Existing 2', 'content' => 'Two'], ], $data['items'], ); } } PK9B\)($o~o~tests/attempt_test.phpnu[. namespace mod_quiz; use core_question\local\bank\condition; use core_question\local\bank\question_version_status; use core_question_generator; use mod_quiz_generator; use question_engine; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->dirroot . '/mod/quiz/locallib.php'); /** * Tests for the quiz_attempt class. * * @package mod_quiz * @category test * @copyright 2014 Tim Hunt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @covers \mod_quiz\quiz_attempt */ final class attempt_test extends \advanced_testcase { /** * Create quiz and attempt data with layout. * * @param string $layout layout to set. Like quiz attempt.layout. E.g. '1,2,0,3,4,0,'. * @param string $navmethod quiz navigation method (defaults to free) * @return quiz_attempt the new quiz_attempt object */ protected function create_quiz_and_attempt_with_layout($layout, $navmethod = QUIZ_NAVMETHOD_FREE) { $this->resetAfterTest(true); // 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, 'navmethod' => $navmethod]); $quizobj = quiz_settings::create($quiz->id, $user->id); $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); $cat = $questiongenerator->create_question_category(); $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); } $attempt = quiz_prepare_and_start_new_attempt($quizobj, 1, null, false, [], [], $user->id); return quiz_attempt::create($attempt->id); } public function test_attempt_url(): void { $attempt = $this->create_quiz_and_attempt_with_layout('1,2,0,3,4,0,5,6,0'); $attemptid = $attempt->get_attempt()->id; $cmid = $attempt->get_cmid(); $url = '/mod/quiz/attempt.php'; $params = ['attempt' => $attemptid, 'cmid' => $cmid, 'page' => 2]; $this->assertEquals(new \moodle_url($url, $params), $attempt->attempt_url(null, 2)); $params['page'] = 1; $this->assertEquals(new \moodle_url($url, $params), $attempt->attempt_url(3)); $questionattempt = $attempt->get_question_attempt(4); $expecteanchor = $questionattempt->get_outer_question_div_unique_id(); $this->assertEquals(new \moodle_url($url, $params, $expecteanchor), $attempt->attempt_url(4)); $questionattempt = $attempt->get_question_attempt(3); $expecteanchor = '#' . $questionattempt->get_outer_question_div_unique_id(); $this->assertEquals(new \moodle_url('#'), $attempt->attempt_url(null, 2, 2)); $this->assertEquals(new \moodle_url($expecteanchor), $attempt->attempt_url(3, -1, 1)); $questionattempt = $attempt->get_question_attempt(4); $expecteanchor = $questionattempt->get_outer_question_div_unique_id(); $this->assertEquals(new \moodle_url(null, null, $expecteanchor, null), $attempt->attempt_url(4, -1, 1)); // Summary page. $url = '/mod/quiz/summary.php'; unset($params['page']); $this->assertEquals(new \moodle_url($url, $params), $attempt->summary_url()); // Review page. $url = '/mod/quiz/review.php'; $this->assertEquals(new \moodle_url($url, $params), $attempt->review_url()); $params['page'] = 1; $this->assertEquals(new \moodle_url($url, $params), $attempt->review_url(3, -1, false)); $this->assertEquals(new \moodle_url($url, $params, $expecteanchor), $attempt->review_url(4, -1, false)); unset($params['page']); $this->assertEquals(new \moodle_url($url, $params), $attempt->review_url(null, 2, true)); $this->assertEquals(new \moodle_url($url, $params), $attempt->review_url(1, -1, true)); $params['page'] = 2; $this->assertEquals(new \moodle_url($url, $params), $attempt->review_url(null, 2, false)); unset($params['page']); $params['showall'] = 0; $this->assertEquals(new \moodle_url($url, $params), $attempt->review_url(null, 0, false)); $this->assertEquals(new \moodle_url($url, $params), $attempt->review_url(1, -1, false)); $params['page'] = 1; unset($params['showall']); $this->assertEquals(new \moodle_url($url, $params), $attempt->review_url(3, -1, false)); $params['page'] = 2; $this->assertEquals(new \moodle_url($url, $params), $attempt->review_url(null, 2)); $this->assertEquals(new \moodle_url('#'), $attempt->review_url(null, -1, null, 0)); $questionattempt = $attempt->get_question_attempt(3); $expecteanchor = '#' . $questionattempt->get_outer_question_div_unique_id(); $this->assertEquals(new \moodle_url($expecteanchor), $attempt->review_url(3, -1, null, 0)); $questionattempt = $attempt->get_question_attempt(4); $expecteanchor = '#' . $questionattempt->get_outer_question_div_unique_id(); $this->assertEquals(new \moodle_url($expecteanchor), $attempt->review_url(4, -1, null, 0)); $this->assertEquals(new \moodle_url('#'), $attempt->review_url(null, 2, true, 0)); $questionattempt = $attempt->get_question_attempt(1); $expecteanchor = '#' . $questionattempt->get_outer_question_div_unique_id(); $this->assertEquals(new \moodle_url($expecteanchor), $attempt->review_url(1, -1, true, 0)); $this->assertEquals(new \moodle_url($expecteanchor), $attempt->review_url(1, -1, false, 0)); $this->assertEquals(new \moodle_url($url, $params), $attempt->review_url(null, 2, false, 0)); $this->assertEquals(new \moodle_url('#'), $attempt->review_url(null, 0, false, 0)); $params['page'] = 1; $this->assertEquals(new \moodle_url($url, $params), $attempt->review_url(3, -1, false, 0)); // Setup another attempt. $attempt = $this->create_quiz_and_attempt_with_layout( '1,2,3,4,5,6,7,8,9,10,0,11,12,13,14,15,16,17,18,19,20,0,' . '21,22,23,24,25,26,27,28,29,30,0,31,32,33,34,35,36,37,38,39,40,0,' . '41,42,43,44,45,46,47,48,49,50,0,51,52,53,54,55,56,57,58,59,60,0'); $attemptid = $attempt->get_attempt()->id; $cmid = $attempt->get_cmid(); $params = ['attempt' => $attemptid, 'cmid' => $cmid]; $this->assertEquals(new \moodle_url($url, $params), $attempt->review_url()); $params['page'] = 2; $this->assertEquals(new \moodle_url($url, $params), $attempt->review_url(null, 2)); $params['page'] = 1; unset($params['showall']); $this->assertEquals(new \moodle_url($url, $params), $attempt->review_url(11, -1, false)); $questionattempt = $attempt->get_question_attempt(12); $expecteanchor = $questionattempt->get_outer_question_div_unique_id(); $this->assertEquals(new \moodle_url($url, $params, $expecteanchor), $attempt->review_url(12, -1, false)); $params['showall'] = 1; unset($params['page']); $this->assertEquals(new \moodle_url($url, $params), $attempt->review_url(null, 2, true)); $this->assertEquals(new \moodle_url($url, $params), $attempt->review_url(1, -1, true)); $params['page'] = 2; unset($params['showall']); $this->assertEquals(new \moodle_url($url, $params), $attempt->review_url(null, 2, false)); unset($params['page']); $this->assertEquals(new \moodle_url($url, $params), $attempt->review_url(null, 0, false)); $params['page'] = 1; $this->assertEquals(new \moodle_url($url, $params), $attempt->review_url(11, -1, false)); $this->assertEquals(new \moodle_url($url, $params, $expecteanchor), $attempt->review_url(12, -1, false)); $params['page'] = 2; $this->assertEquals(new \moodle_url($url, $params), $attempt->review_url(null, 2)); $this->assertEquals(new \moodle_url('#'), $attempt->review_url(null, -1, null, 0)); $questionattempt = $attempt->get_question_attempt(3); $expecteanchor = $questionattempt->get_outer_question_div_unique_id(); $this->assertEquals(new \moodle_url(null, null, $expecteanchor), $attempt->review_url(3, -1, null, 0)); $questionattempt = $attempt->get_question_attempt(4); $expecteanchor = $questionattempt->get_outer_question_div_unique_id(); $this->assertEquals(new \moodle_url(null, null, $expecteanchor), $attempt->review_url(4, -1, null, 0)); $questionattempt = $attempt->get_question_attempt(1); $expecteanchor = '#' . $questionattempt->get_outer_question_div_unique_id(); $this->assertEquals(new \moodle_url($expecteanchor), $attempt->review_url(1, -1, true, 0)); $this->assertEquals(new \moodle_url('#'), $attempt->review_url(null, 2, true, 0)); $params['page'] = 2; $questionattempt = $attempt->get_question_attempt(1); $expecteanchor = '#' . $questionattempt->get_outer_question_div_unique_id(); $this->assertEquals(new \moodle_url($expecteanchor), $attempt->review_url(1, -1, false, 0)); $this->assertEquals(new \moodle_url($url, $params), $attempt->review_url(null, 2, false, 0)); $this->assertEquals(new \moodle_url('#'), $attempt->review_url(null, 0, false, 0)); $params['page'] = 1; $this->assertEquals(new \moodle_url($url, $params), $attempt->review_url(11, -1, false, 0)); } /** * Tests attempt page titles when all questions are on a single page. */ public function test_attempt_titles_single(): void { $attempt = $this->create_quiz_and_attempt_with_layout('1,2,0'); // Attempt page. $this->assertEquals('Quiz 1', $attempt->attempt_page_title(0)); // Summary page. $this->assertEquals('Quiz 1: Attempt summary', $attempt->summary_page_title()); // Review page. $this->assertEquals('Quiz 1: Attempt review', $attempt->review_page_title(0)); } /** * Tests attempt page titles when questions are on multiple pages, but are reviewed on a single page. */ public function test_attempt_titles_multiple_single(): void { $attempt = $this->create_quiz_and_attempt_with_layout('1,2,0,3,4,0,5,6,0'); // Attempt page. $this->assertEquals('Quiz 1 (page 1 of 3)', $attempt->attempt_page_title(0)); $this->assertEquals('Quiz 1 (page 2 of 3)', $attempt->attempt_page_title(1)); $this->assertEquals('Quiz 1 (page 3 of 3)', $attempt->attempt_page_title(2)); // Summary page. $this->assertEquals('Quiz 1: Attempt summary', $attempt->summary_page_title()); // Review page. $this->assertEquals('Quiz 1: Attempt review', $attempt->review_page_title(0, true)); } /** * Tests attempt page titles when questions are on multiple pages, and they are reviewed on multiple pages as well. */ public function test_attempt_titles_multiple_multiple(): void { $attempt = $this->create_quiz_and_attempt_with_layout( '1,2,3,4,5,6,7,8,9,10,0,11,12,13,14,15,16,17,18,19,20,0,' . '21,22,23,24,25,26,27,28,29,30,0,31,32,33,34,35,36,37,38,39,40,0,' . '41,42,43,44,45,46,47,48,49,50,0,51,52,53,54,55,56,57,58,59,60,0'); // Attempt page. $this->assertEquals('Quiz 1 (page 1 of 6)', $attempt->attempt_page_title(0)); $this->assertEquals('Quiz 1 (page 2 of 6)', $attempt->attempt_page_title(1)); $this->assertEquals('Quiz 1 (page 6 of 6)', $attempt->attempt_page_title(5)); // Summary page. $this->assertEquals('Quiz 1: Attempt summary', $attempt->summary_page_title()); // Review page. $this->assertEquals('Quiz 1: Attempt review (page 1 of 6)', $attempt->review_page_title(0)); $this->assertEquals('Quiz 1: Attempt review (page 2 of 6)', $attempt->review_page_title(1)); $this->assertEquals('Quiz 1: Attempt review (page 6 of 6)', $attempt->review_page_title(5)); // When all questions are shown. $this->assertEquals('Quiz 1: Attempt review', $attempt->review_page_title(0, true)); $this->assertEquals('Quiz 1: Attempt review', $attempt->review_page_title(1, true)); } public function test_is_participant(): void { global $USER; $this->resetAfterTest(); $this->setAdminUser(); $course = $this->getDataGenerator()->create_course(); $student = $this->getDataGenerator()->create_and_enrol($course, 'student'); $student2 = $this->getDataGenerator()->create_and_enrol($course, 'student', [], 'manual', 0, 0, ENROL_USER_SUSPENDED); $quiz = $this->getDataGenerator()->create_module('quiz', ['course' => $course->id]); $quizobj = quiz_settings::create($quiz->id); // Login as student. $this->setUser($student); // Convert to a lesson object. $this->assertEquals(true, $quizobj->is_participant($student->id), 'Student is enrolled, active and can participate'); // Login as student2. $this->setUser($student2); $this->assertEquals(false, $quizobj->is_participant($student2->id), 'Student is enrolled, suspended and can NOT participate'); // Login as an admin. $this->setAdminUser(); $this->assertEquals(false, $quizobj->is_participant($USER->id), 'Admin is not enrolled and can NOT participate'); $this->getDataGenerator()->enrol_user(2, $course->id); $this->assertEquals(true, $quizobj->is_participant($USER->id), 'Admin is enrolled and can participate'); $this->getDataGenerator()->enrol_user(2, $course->id, null, 'manual', 0, 0, ENROL_USER_SUSPENDED); $this->assertEquals(true, $quizobj->is_participant($USER->id), 'Admin is enrolled, suspended and can participate'); } /** * Test quiz_prepare_and_start_new_attempt function */ public function test_quiz_prepare_and_start_new_attempt(): void { global $USER; $this->resetAfterTest(); // Create course. $course = $this->getDataGenerator()->create_course(); // Create students. $student1 = $this->getDataGenerator()->create_and_enrol($course, 'student'); $student2 = $this->getDataGenerator()->create_and_enrol($course, 'student'); // Create quiz. $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz'); $quiz = $quizgenerator->create_instance(['course' => $course->id, 'grade' => 100.0, 'sumgrades' => 2, 'layout' => '1,0']); // Create question and add it to quiz. $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); $cat = $questiongenerator->create_question_category(); $question = $questiongenerator->create_question('shortanswer', null, ['category' => $cat->id]); quiz_add_quiz_question($question->id, $quiz, 1); $quizobj = quiz_settings::create($quiz->id); // Login as student1. $this->setUser($student1); // Create attempt for student1. $attempt = quiz_prepare_and_start_new_attempt($quizobj, 1, null, false, [], []); $this->assertEquals($student1->id, $attempt->userid); $this->assertEquals(0, $attempt->preview); // Login as student2. $this->setUser($student2); // Create attempt for student2. $attempt = quiz_prepare_and_start_new_attempt($quizobj, 1, null, false, [], []); $this->assertEquals($student2->id, $attempt->userid); $this->assertEquals(0, $attempt->preview); // Login as admin. $this->setAdminUser(); // Create attempt for student1. $attempt = quiz_prepare_and_start_new_attempt($quizobj, 2, null, false, [], [], $student1->id); $this->assertEquals($student1->id, $attempt->userid); $this->assertEquals(0, $attempt->preview); $student1attempt = $attempt; // Save for extra verification below. // Create attempt for student2. $attempt = quiz_prepare_and_start_new_attempt($quizobj, 2, null, false, [], [], $student2->id); $this->assertEquals($student2->id, $attempt->userid); $this->assertEquals(0, $attempt->preview); // Create attempt for user id that the same with current $USER->id. $attempt = quiz_prepare_and_start_new_attempt($quizobj, 2, null, false, [], [], $USER->id); $this->assertEquals($USER->id, $attempt->userid); $this->assertEquals(1, $attempt->preview); // Check that the userid stored in the first step is the user the attempt is for, // not the user who triggered the creation. $quba = question_engine::load_questions_usage_by_activity($student1attempt->uniqueid); $step = $quba->get_question_attempt(1)->get_step(0); $this->assertEquals($student1->id, $step->get_user_id()); } /** * Test quiz_prepare_and_start_new_attempt function */ public function test_quiz_prepare_and_start_new_attempt_random_draft(): void { $this->resetAfterTest(); $this->setAdminUser(); // Create course. $course = $this->getDataGenerator()->create_course(); // Create quiz. /** @var mod_quiz_generator $quizgenerator */ $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz'); $quiz = $quizgenerator->create_instance(['course' => $course->id]); // Create question with 2 versions. V1 ready. V2 draft. /** @var core_question_generator $questiongenerator */ $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); $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]); // Add a random question form that category. $filtercondition = [ 'filter' => [ 'category' => [ 'jointype' => condition::JOINTYPE_DEFAULT, 'values' => [$category->id], 'filteroptions' => ['includesubcategories' => false], ], ], ]; $quizobj = quiz_settings::create($quiz->id); $quizobj->get_structure()->add_random_questions(1, 1, $filtercondition); $quizobj->get_grade_calculator()->recompute_quiz_sumgrades(); // Create an attempt. $quizobj = quiz_settings::create($quiz->id); $attempt = quiz_prepare_and_start_new_attempt($quizobj, 1, null); $this->assertEquals(1, $attempt->preview); } /** * Test check_page_access function * @covers \quiz_attempt::check_page_access */ public function test_check_page_access(): void { $timenow = time(); // Free navigation. $attempt = $this->create_quiz_and_attempt_with_layout('1,0,2,0,3,0,4,0,5,0', QUIZ_NAVMETHOD_FREE); // Check access. $this->assertTrue($attempt->check_page_access(4)); $this->assertTrue($attempt->check_page_access(3)); $this->assertTrue($attempt->check_page_access(2)); $this->assertTrue($attempt->check_page_access(1)); $this->assertTrue($attempt->check_page_access(0)); $this->assertTrue($attempt->check_page_access(2)); // Access page 2. $attempt->set_currentpage(2); $attempt = quiz_attempt::create($attempt->get_attempt()->id); // Check access. $this->assertTrue($attempt->check_page_access(0)); $this->assertTrue($attempt->check_page_access(1)); $this->assertTrue($attempt->check_page_access(2)); $this->assertTrue($attempt->check_page_access(3)); $this->assertTrue($attempt->check_page_access(4)); // Sequential navigation. $attempt = $this->create_quiz_and_attempt_with_layout('1,0,2,0,3,0,4,0,5,0', QUIZ_NAVMETHOD_SEQ); // Check access. $this->assertTrue($attempt->check_page_access(0)); $this->assertTrue($attempt->check_page_access(1)); $this->assertFalse($attempt->check_page_access(2)); $this->assertFalse($attempt->check_page_access(3)); $this->assertFalse($attempt->check_page_access(4)); // Access page 1. $attempt->set_currentpage(1); $attempt = quiz_attempt::create($attempt->get_attempt()->id); $this->assertTrue($attempt->check_page_access(1)); // Access page 2. $attempt->set_currentpage(2); $attempt = quiz_attempt::create($attempt->get_attempt()->id); $this->assertTrue($attempt->check_page_access(2)); $this->assertTrue($attempt->check_page_access(3)); $this->assertFalse($attempt->check_page_access(4)); $this->assertFalse($attempt->check_page_access(1)); } /** * Starting a new attempt with a question in draft status should throw an exception. * * @covers ::quiz_start_new_attempt() * @return void */ public function test_start_new_attempt_with_draft(): void { $this->resetAfterTest(); // Create course. $course = $this->getDataGenerator()->create_course(); // Create students. $student1 = $this->getDataGenerator()->create_and_enrol($course, 'student'); $student2 = $this->getDataGenerator()->create_and_enrol($course, 'student'); // Create quiz. $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz'); $quiz = $quizgenerator->create_instance(['course' => $course->id, 'grade' => 100.0, 'sumgrades' => 2, 'layout' => '1,0']); // Create question and add it to quiz. $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); $cat = $questiongenerator->create_question_category(); $question = $questiongenerator->create_question('shortanswer', null, ['category' => $cat->id, 'status' => question_version_status::QUESTION_STATUS_DRAFT]); quiz_add_quiz_question($question->id, $quiz, 1); $quizobj = quiz_settings::create($quiz->id); $quba = question_engine::make_questions_usage_by_activity('mod_quiz', $quizobj->get_context()); $quba->set_preferred_behaviour($quizobj->get_quiz()->preferredbehaviour); $attempt = quiz_create_attempt($quizobj, 1, false, time(), false, $student1->id); $this->expectExceptionObject(new \moodle_exception('questiondraftonly', 'mod_quiz', '', $question->name)); quiz_start_new_attempt($quizobj, $quba, $attempt, 1, time()); } /** * Starting a new attempt built on last with a question in draft status should throw an exception. * * @covers ::quiz_start_attempt_built_on_last() * @return void */ public function test_quiz_start_attempt_built_on_last_with_draft(): void { global $DB; $this->resetAfterTest(); // Create course. $course = $this->getDataGenerator()->create_course(); // Create students. $student1 = $this->getDataGenerator()->create_and_enrol($course, 'student'); $student2 = $this->getDataGenerator()->create_and_enrol($course, 'student'); // Create quiz. $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz'); $quiz = $quizgenerator->create_instance(['course' => $course->id, 'grade' => 100.0, 'sumgrades' => 2, 'layout' => '1,0']); // Create question and add it to quiz. $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); $cat = $questiongenerator->create_question_category(); $question = $questiongenerator->create_question('shortanswer', null, ['category' => $cat->id]); quiz_add_quiz_question($question->id, $quiz, 1); $quizobj = quiz_settings::create($quiz->id); $quba = question_engine::make_questions_usage_by_activity('mod_quiz', $quizobj->get_context()); $quba->set_preferred_behaviour($quizobj->get_quiz()->preferredbehaviour); $attempt = quiz_create_attempt($quizobj, 1, false, time(), false, $student1->id); $attempt = quiz_start_new_attempt($quizobj, $quba, $attempt, 1, time()); $attempt = quiz_attempt_save_started($quizobj, $quba, $attempt); $DB->set_field('question_versions', 'status', question_version_status::QUESTION_STATUS_DRAFT, ['questionid' => $question->id]); // We need to reset the cache since the question has been edited by changing its status to draft. \question_bank::notify_question_edited($question->id); $quizobj = quiz_settings::create($quiz->id); $quba = question_engine::make_questions_usage_by_activity('mod_quiz', $quizobj->get_context()); $quba->set_preferred_behaviour($quizobj->get_quiz()->preferredbehaviour); $newattempt = quiz_create_attempt($quizobj, 2, $attempt, time(), false, $student1->id); $this->expectExceptionObject(new \moodle_exception('questiondraftonly', 'mod_quiz', '', $question->name)); quiz_start_attempt_built_on_last($quba, $newattempt, $attempt); } public function test_get_grade_item_totals(): void { $attemptobj = $this->create_quiz_and_attempt_with_layout('1,2,3,0'); /** @var mod_quiz_generator $quizgenerator */ $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz'); // Set up some section grades. $listeninggrade = $quizgenerator->create_grade_item(['quizid' => $attemptobj->get_quizid(), 'name' => 'Listening']); $readinggrade = $quizgenerator->create_grade_item(['quizid' => $attemptobj->get_quizid(), 'name' => 'Reading']); $structure = $attemptobj->get_quizobj()->get_structure(); $structure->update_slot_grade_item($structure->get_slot_by_number(1), $listeninggrade->id); $structure->update_slot_grade_item($structure->get_slot_by_number(2), $listeninggrade->id); $structure->update_slot_grade_item($structure->get_slot_by_number(3), $readinggrade->id); // Reload the attempt and verify. $attemptobj = quiz_attempt::create($attemptobj->get_attemptid()); $grades = $attemptobj->get_grade_item_totals(); // All grades zero because student has not done the quiz yet, but this is a sufficent test. $this->assertEquals('Listening', $grades[$listeninggrade->id]->name); $this->assertEquals(0, $grades[$listeninggrade->id]->grade); $this->assertEquals(2, $grades[$listeninggrade->id]->maxgrade); $this->assertEquals('Reading', $grades[$readinggrade->id]->name); $this->assertEquals(0, $grades[$readinggrade->id]->grade); $this->assertEquals(1, $grades[$readinggrade->id]->maxgrade); } /** * When creating a new quiz attempt, question attempts should be created with the first step's timecreated set to null. * * When the question attempt is rendered, it should be set to the current time. * * @return void * @throws \coding_exception * @covers ::quiz_start_new_attempt */ public function test_step_timecreated_unset_when_starting_quiz_attempt(): void { $attempt = $this->create_quiz_and_attempt_with_layout('1'); $questionattempt = $attempt->get_question_attempt(1); $this->assertEquals(\question_attempt_step::TIMECREATED_ON_FIRST_RENDER, $questionattempt->get_step(0)->get_timecreated()); $questionattempt->render(new \question_display_options(), 1); $this->assertEqualsWithDelta(time(), $questionattempt->get_step(0)->get_timecreated(), 1); } /** * Test that enabling shuffle on the first quiz section randomizes question order between attempts. * * @return void * @covers ::quiz_start_new_attempt */ public function test_question_shuffle(): void { $this->resetAfterTest(); $user = $this->getDataGenerator()->create_user(); $this->setUser($user); // Create quiz with two sections (shuffled, non-shuffled) using create_test_quiz. $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz'); $quizobj = $quizgenerator->create_test_quiz([ 'Shuffled section*', ['Q1', 1, 'shortanswer'], ['Q2', 1, 'shortanswer'], ['Q3', 1, 'shortanswer'], ['Q4', 1, 'shortanswer'], ['Q5', 1, 'shortanswer'], 'Non-shuffled section', ['Q6', 2, 'shortanswer'], ['Q7', 2, 'shortanswer'], ['Q8', 2, 'shortanswer'], ['Q9', 2, 'shortanswer'], ['Q10', 2, 'shortanswer'], ]); // Start the reference attempt (Attempt 1). $attempt1 = quiz_prepare_and_start_new_attempt($quizobj, 1, null, false, [], [], $user->id); $attemptobj1 = quiz_attempt::create($attempt1->id); // Shuffled section $slots1a = $attemptobj1->get_slots(0); // Non-shuffled section $slots2a = $attemptobj1->get_slots(1); // Get reference orders. $order1a = array_map(fn($slot) => $attemptobj1->get_question_attempt($slot)->get_question()->id, $slots1a); $order2a = array_map(fn($slot) => $attemptobj1->get_question_attempt($slot)->get_question()->id, $slots2a); // Start comparison attempt (Attempt 2) with a retry mechanism. // We try up to 5 times to get a different shuffle. // If it matches 5 times in a row, the shuffle feature is likely broken. $maxretries = 5; $isshuffled = false; for ($i = 0; $i < $maxretries; $i++) { $attempt2 = quiz_prepare_and_start_new_attempt($quizobj, 2 + $i, null, false, [], [], $user->id); $attemptobj2 = quiz_attempt::create($attempt2->id); $slots1b = $attemptobj2->get_slots(0); $order1b = array_map(fn($slot) => $attemptobj2->get_question_attempt($slot)->get_question()->id, $slots1b); // If the orders are different, the shuffle is working. if ($order1a !== $order1b) { $isshuffled = true; break; } // Otherwise try again. } // Assert that we eventually found a different order. $message = "Shuffled section should have different order between attempts after $maxretries tries."; $this->assertTrue($isshuffled, $message); // Verify the non-shuffled section on the final attempt used. // We only need to check this on the last attempt generated, as it should never change. $slots2b = $attemptobj2->get_slots(1); $order2b = array_map(fn($slot) => $attemptobj2->get_question_attempt($slot)->get_question()->id, $slots2b); $this->assertEquals($order2a, $order2b, 'Non-shuffled section should have same order between attempts.'); } } PK9B\ʔtests/dates_test.phpnu[. /** * Contains unit tests for mod_quiz\dates. * * @package mod_quiz * @category test * @copyright 2021 Shamim Rezaie * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ declare(strict_types=1); namespace mod_quiz; use advanced_testcase; use cm_info; use core\activity_dates; /** * Class for unit testing mod_quiz\dates. * * @copyright 2021 Shamim Rezaie * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class dates_test extends advanced_testcase { /** * Data provider for get_dates_for_module(). * @return array[] */ public static function get_dates_for_module_provider(): array { $now = time(); $before = $now - DAYSECS; $earlier = $before - DAYSECS; $after = $now + DAYSECS; $later = $after + DAYSECS; return [ 'without any dates' => [ null, null, null, null, null, null, [] ], 'only with opening time' => [ $after, null, null, null, null, null, [ ['label' => get_string('activitydate:opens', 'course'), 'timestamp' => $after, 'dataid' => 'timeopen'], ] ], 'only with closing time' => [ null, $after, null, null, null, null, [ ['label' => get_string('activitydate:closes', 'course'), 'timestamp' => $after, 'dataid' => 'timeclose'], ] ], 'with both times' => [ $after, $later, null, null, null, null, [ ['label' => get_string('activitydate:opens', 'course'), 'timestamp' => $after, 'dataid' => 'timeopen'], ['label' => get_string('activitydate:closes', 'course'), 'timestamp' => $later, 'dataid' => 'timeclose'], ] ], 'between the dates' => [ $before, $after, null, null, null, null, [ ['label' => get_string('activitydate:opened', 'course'), 'timestamp' => $before, 'dataid' => 'timeopen'], ['label' => get_string('activitydate:closes', 'course'), 'timestamp' => $after, 'dataid' => 'timeclose'], ] ], 'dates are past' => [ $earlier, $before, null, null, null, null, [ ['label' => get_string('activitydate:opened', 'course'), 'timestamp' => $earlier, 'dataid' => 'timeopen'], ['label' => get_string('activitydate:closed', 'course'), 'timestamp' => $before, 'dataid' => 'timeclose'], ] ], 'with user override' => [ $before, $after, $earlier, $later, null, null, [ ['label' => get_string('activitydate:opened', 'course'), 'timestamp' => $earlier, 'dataid' => 'timeopen'], ['label' => get_string('activitydate:closes', 'course'), 'timestamp' => $later, 'dataid' => 'timeclose'], ] ], 'with group override' => [ $before, $after, null, null, $earlier, $later, [ ['label' => get_string('activitydate:opened', 'course'), 'timestamp' => $earlier, 'dataid' => 'timeopen'], ['label' => get_string('activitydate:closes', 'course'), 'timestamp' => $later, 'dataid' => 'timeclose'], ] ], 'with both user and group overrides' => [ $before, $after, $earlier, $later, $earlier - DAYSECS, $later + DAYSECS, [ ['label' => get_string('activitydate:opened', 'course'), 'timestamp' => $earlier, 'dataid' => 'timeopen'], ['label' => get_string('activitydate:closes', 'course'), 'timestamp' => $later, 'dataid' => 'timeclose'], ] ], ]; } /** * Test for get_dates_for_module(). * * @dataProvider get_dates_for_module_provider * @param int|null $timeopen Time of opening the quiz. * @param int|null $timeclose Time of closing the quiz. * @param int|null $usertimeopen The user override for opening the quiz. * @param int|null $usertimeclose The user override for closing the quiz. * @param int|null $grouptimeopen The group override for opening the quiz. * @param int|null $grouptimeclose The group override for closing the quiz. * @param array $expected The expected value of calling get_dates_for_module() */ public function test_get_dates_for_module(?int $timeopen, ?int $timeclose, ?int $usertimeopen, ?int $usertimeclose, ?int $grouptimeopen, ?int $grouptimeclose, array $expected): void { $this->resetAfterTest(); $generator = $this->getDataGenerator(); /** @var \mod_quiz_generator $quizgenerator */ $quizgenerator = $generator->get_plugin_generator('mod_quiz'); $course = $generator->create_course(); $user = $generator->create_user(); $generator->enrol_user($user->id, $course->id); $data = ['course' => $course->id]; if ($timeopen) { $data['timeopen'] = $timeopen; } if ($timeclose) { $data['timeclose'] = $timeclose; } $quiz = $quizgenerator->create_instance($data); if ($usertimeopen || $usertimeclose || $grouptimeopen || $grouptimeclose) { $generator->enrol_user($user->id, $course->id); $group = $generator->create_group(['courseid' => $course->id]); $generator->create_group_member(['groupid' => $group->id, 'userid' => $user->id]); if ($usertimeopen || $usertimeclose) { $quizgenerator->create_override([ 'quiz' => $quiz->id, 'userid' => $user->id, 'timeopen' => $usertimeopen, 'timeclose' => $usertimeclose, ]); } if ($grouptimeopen || $grouptimeclose) { $quizgenerator->create_override([ 'quiz' => $quiz->id, 'groupid' => $group->id, 'timeopen' => $grouptimeopen, 'timeclose' => $grouptimeclose, ]); } } $this->setUser($user); $cm = get_coursemodule_from_instance('quiz', $quiz->id); // Make sure we're using a cm_info object. $cm = cm_info::create($cm); $dates = activity_dates::get_dates_for_module($cm, (int) $user->id); $this->assertEquals($expected, $dates); } } PK9B\{&q//$tests/quiz_question_version_test.phpnu[. namespace mod_quiz; use core_question\local\bank\condition; use mod_quiz\external\submit_question_version; use mod_quiz\question\bank\qbank_helper; defined('MOODLE_INTERNAL') || die(); require_once(__DIR__ . '/quiz_question_helper_test_trait.php'); /** * Question versions test for quiz. * * @package mod_quiz * @category test * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @covers \mod_quiz\question\bank\qbank_helper */ final class quiz_question_version_test extends \advanced_testcase { use \quiz_question_helper_test_trait; /** @var \stdClass user record. */ protected $student; /** * Called before every test. */ public function setUp(): void { global $USER; parent::setUp(); $this->setAdminUser(); $this->course = $this->getDataGenerator()->create_course(); $this->student = $this->getDataGenerator()->create_user(); $this->user = $USER; } /** * Test the quiz question data for changed version in the slots. */ public function test_quiz_questions_for_changed_versions(): void { $this->resetAfterTest(); $quiz = $this->create_test_quiz($this->course); // Test for questions from a different context. $context = \context_module::instance(get_coursemodule_from_instance("quiz", $quiz->id, $this->course->id)->id); $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); // Create a couple of questions. $cat = $questiongenerator->create_question_category(['contextid' => $context->id]); $numq = $questiongenerator->create_question('essay', null, ['category' => $cat->id, 'name' => 'This is the first version']); // Create two version. $questiongenerator->update_question($numq, null, ['name' => 'This is the second version']); $questiongenerator->update_question($numq, null, ['name' => 'This is the third version']); quiz_add_quiz_question($numq->id, $quiz); // Create the quiz object. $quizobj = \mod_quiz\quiz_settings::create($quiz->id); $structure = \mod_quiz\structure::create_for_quiz($quizobj); $slots = $structure->get_slots(); $slot = reset($slots); // Test that the version added is 'always latest'. $this->assertEquals(3, $slot->version); $quizobj->preload_questions(); $quizobj->load_questions(); $questions = $quizobj->get_questions(); $question = reset($questions); $this->assertEquals(3, $question->version); $this->assertEquals('This is the third version', $question->name); // Create another version. $questiongenerator->update_question($numq, null, ['name' => 'This is the latest version']); // Check that 'Always latest is working'. $quizobj->preload_questions(); $quizobj->load_questions(); $questions = $quizobj->get_questions(); $question = reset($questions); $this->assertEquals(4, $question->version); $this->assertEquals('This is the latest version', $question->name); $structure = \mod_quiz\structure::create_for_quiz($quizobj); $slots = $structure->get_slots(); $slot = reset($slots); $this->assertEquals(4, $slot->version); // Now change the version using the external service. $versions = qbank_helper::get_version_options($slot->questionid); $this->assertDebuggingCalled(); // We don't want the current version. $selectversions = []; foreach ($versions as $version) { if ($version->version === $slot->version) { continue; } $selectversions [$version->version] = $version; } // Change to version 1. submit_question_version::execute($slot->id, (int)$selectversions[1]->version); $quizobj->preload_questions(); $quizobj->load_questions(); $questions = $quizobj->get_questions(); $question = reset($questions); $this->assertEquals(1, $question->version); $this->assertEquals('This is the first version', $question->name); $structure = \mod_quiz\structure::create_for_quiz($quizobj); $slots = $structure->get_slots(); $slot = reset($slots); $this->assertEquals(1, $slot->version); // Change to version 2. submit_question_version::execute($slot->id, $selectversions[2]->version); $quizobj->preload_questions(); $quizobj->load_questions(); $questions = $quizobj->get_questions(); $question = reset($questions); $this->assertEquals(2, $question->version); $this->assertEquals('This is the second version', $question->name); $structure = \mod_quiz\structure::create_for_quiz($quizobj); $slots = $structure->get_slots(); $slot = reset($slots); $this->assertEquals(2, $slot->version); } /** * Test if changing the version of the slot changes the attempts. */ public function test_quiz_question_attempts_with_changed_version(): void { $this->resetAfterTest(); $quiz = $this->create_test_quiz($this->course); // Test for questions from a different context. $context = \context_module::instance(get_coursemodule_from_instance("quiz", $quiz->id, $this->course->id)->id); $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); // Create a couple of questions. $cat = $questiongenerator->create_question_category(['contextid' => $context->id]); $numq = $questiongenerator->create_question('numerical', null, ['category' => $cat->id, 'name' => 'This is the first version']); // Create two version. $questiongenerator->update_question($numq, null, ['name' => 'This is the second version']); $questiongenerator->update_question($numq, null, ['name' => 'This is the third version']); quiz_add_quiz_question($numq->id, $quiz); [, , $attemptobj] = $this->attempt_quiz($quiz, $this->student); $this->assertEquals('This is the third version', $attemptobj->get_question_attempt(1)->get_question()->name); // Create the quiz object. $quizobj = \mod_quiz\quiz_settings::create($quiz->id); $structure = \mod_quiz\structure::create_for_quiz($quizobj); $slots = $structure->get_slots(); $slot = reset($slots); // Now change the version using the external service. $versions = qbank_helper::get_version_options($slot->questionid); $this->assertDebuggingCalled(); // We dont want the current version. $selectversions = []; foreach ($versions as $version) { if ($version->version === $slot->version) { continue; } $selectversions [$version->version] = $version; } // Change to version 1. $this->expectException('moodle_exception'); submit_question_version::execute($slot->id, (int)$selectversions[1]->version); [, , $attemptobj] = $this->attempt_quiz($quiz, $this->student, 2); $this->assertEquals('This is the first version', $attemptobj->get_question_attempt(1)->get_question()->name); // Change to version 2. submit_question_version::execute($slot->id, (int)$selectversions[2]->version); [, , $attemptobj] = $this->attempt_quiz($quiz, $this->student, 3); $this->assertEquals('This is the second version', $attemptobj->get_question_attempt(1)->get_question()->name); // Create another version. $questiongenerator->update_question($numq, null, ['name' => 'This is the latest version']); // Change to always latest. submit_question_version::execute($slot->id, 0); [, , $attemptobj] = $this->attempt_quiz($quiz, $this->student, 4); $this->assertEquals('This is the latest version', $attemptobj->get_question_attempt(1)->get_question()->name); } public function test_get_version_information_for_questions_in_attempt(): void { $this->resetAfterTest(); /** @var \mod_quiz_generator $quizgenerator */ $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz'); /** @var \core_question_generator $questiongenerator */ $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); // Make two categories, each with a question. $coursecontext = \context_course::instance($this->course->id); $cat = $questiongenerator->create_question_category( ['name' => 'Non-random questions', 'context' => $coursecontext->id]); $randomcat = $questiongenerator->create_question_category( ['name' => 'Random questions', 'context' => $coursecontext->id]); $q1 = $questiongenerator->create_question('truefalse', null, ['category' => $cat->id]); $q2 = $questiongenerator->create_question('truefalse', null, ['category' => $randomcat->id]); // Make the quiz, adding q1, and a random question from randomcat. $quiz = $quizgenerator->create_instance([ 'course' => $this->course->id, 'grade' => 100.0, 'sumgrades' => 2, 'canredoquestions' => 1, 'preferredbehaviour' => 'immediatefeedback', ]); $quizobj = quiz_settings::create($quiz->id); quiz_add_quiz_question($q1->id, $quiz); $structure = $quizobj->get_structure(); $structure->add_random_questions(0, 1, [ 'filter' => [ 'category' => [ 'jointype' => condition::JOINTYPE_DEFAULT, 'values' => [$randomcat->id], 'filteroptions' => ['includesubcategories' => false], ], ], ]); // Student starts attempt. $quizobj = quiz_settings::create($quiz->id); $attempt = quiz_prepare_and_start_new_attempt($quizobj, 1, null); $attemptobj = quiz_attempt::create($attempt->id); // Answer both questions. $postdata = $questiongenerator->get_simulated_post_data_for_questions_in_usage( $attemptobj->get_question_usage(), [1 => 'True', 2 => 'False'], true, ); $attemptobj->process_submitted_actions(time(), false, $postdata); // Redo both questions - need to re-create attemptobj each time. $attemptobj = quiz_attempt::create($attempt->id); $attemptobj->process_redo_question(1, time()); $attemptobj = quiz_attempt::create($attempt->id); $attemptobj->process_redo_question(2, time()); // Edit both questions to make a second version. $questiongenerator->update_question($q1); $questiongenerator->update_question($q2); // Finally! call the method we want to test. $versioninfo = qbank_helper::get_version_information_for_questions_in_attempt( $attemptobj->get_attempt(), $attemptobj->get_context()); // Verify - all questions should now want to be V2 for various reasons. $this->assertEquals(2, $versioninfo[1]->newversion); $this->assertEquals(2, $versioninfo[2]->newversion); $this->assertEquals(2, $versioninfo[3]->newversion); $this->assertEquals(2, $versioninfo[4]->newversion); } } PK9B\L::"tests/notification_helper_test.phpnu[. namespace mod_quiz; /** * Test class for the quiz notification helper. * * @package mod_quiz * @category test * @copyright 2024 David Woloszyn * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @covers \mod_quiz\notification_helper */ final class notification_helper_test extends \advanced_testcase { /** * Run all the tasks related to the notifications. */ protected function run_notification_helper_tasks(): void { $task = \core\task\manager::get_scheduled_task(\mod_quiz\task\queue_all_quiz_open_notification_tasks::class); $task->execute(); $clock = \core\di::get(\core\clock::class); $adhoctask = \core\task\manager::get_next_adhoc_task($clock->time()); if ($adhoctask) { $this->assertInstanceOf(\mod_quiz\task\queue_quiz_open_notification_tasks_for_users::class, $adhoctask); $adhoctask->execute(); \core\task\manager::adhoc_task_complete($adhoctask); } $adhoctask = \core\task\manager::get_next_adhoc_task($clock->time()); if ($adhoctask) { $this->assertInstanceOf(\mod_quiz\task\send_quiz_open_soon_notification_to_user::class, $adhoctask); $adhoctask->execute(); \core\task\manager::adhoc_task_complete($adhoctask); } } /** * Test getting quizzes with a 'timeopen' date within the date range. */ public function test_get_quizzes_within_date_range(): void { $this->resetAfterTest(); $generator = $this->getDataGenerator(); $helper = \core\di::get(notification_helper::class); $clock = $this->mock_clock_with_frozen(); // Create a quiz with an open date < 48 hours. $course = $generator->create_course(); $generator->create_module('quiz', ['course' => $course->id, 'timeopen' => $clock->time() + DAYSECS]); // Check that we have a result returned. $result = $helper::get_quizzes_within_date_range(); $this->assertTrue($result->valid()); $result->close(); // Time travel 3 days into the future. We should have no quizzes in range. $clock->bump(DAYSECS * 3); $result = $helper::get_quizzes_within_date_range(); $this->assertFalse($result->valid()); $result->close(); } /** * Test getting users within a quiz that are within our date range. */ public function test_get_users_within_quiz(): void { global $DB; $this->resetAfterTest(); $generator = $this->getDataGenerator(); $helper = \core\di::get(notification_helper::class); $clock = $this->mock_clock_with_frozen(); // Create a course and enrol some users. $course = $generator->create_course(); $user1 = $generator->create_user(); $user2 = $generator->create_user(); $user3 = $generator->create_user(); $user4 = $generator->create_user(); $user5 = $generator->create_user(); $user6 = $generator->create_user(['suspended' => 1]); $user7 = $generator->create_user(); $generator->enrol_user($user1->id, $course->id, 'student'); $generator->enrol_user($user2->id, $course->id, 'student'); $generator->enrol_user($user3->id, $course->id, 'student'); $generator->enrol_user($user4->id, $course->id, 'student'); $generator->enrol_user($user5->id, $course->id, 'teacher'); $generator->enrol_user($user6->id, $course->id, 'student'); $generator->enrol_user($user7->id, $course->id, 'student', 'manual', 0, 0, ENROL_USER_SUSPENDED); /** @var \mod_quiz_generator $quizgenerator */ $quizgenerator = $generator->get_plugin_generator('mod_quiz'); // Create a quiz with an open date < 48 hours. $timeopen = $clock->time() + DAYSECS; $quiz = $quizgenerator->create_instance([ 'course' => $course->id, 'timeopen' => $timeopen, ]); // User1 will have a user specific override, giving them an extra 1 hour for 'timeopen'. $usertimeopen = $timeopen + HOURSECS; $quizgenerator->create_override([ 'quiz' => $quiz->id, 'userid' => $user1->id, 'timeopen' => $usertimeopen, ]); // User2 and user3 will have a group override, giving them an extra 2 hours for 'timeopen'. $grouptimeopen = $timeopen + (HOURSECS * 2); $group = $generator->create_group(['courseid' => $course->id]); $generator->create_group_member(['groupid' => $group->id, 'userid' => $user2->id]); $generator->create_group_member(['groupid' => $group->id, 'userid' => $user3->id]); $quizgenerator->create_override([ 'quiz' => $quiz->id, 'groupid' => $group->id, 'timeopen' => $grouptimeopen, ]); // Get the users within the date range. $quizzes = $helper::get_quizzes_within_date_range(); foreach ($quizzes as $q) { $users = $helper::get_users_within_quiz($q->id); } $quizzes->close(); // User1 has the 'user' override and its 'timeopen' date has been updated. $this->assertEquals($usertimeopen, $users[$user1->id]->timeopen); $this->assertEquals('user', $users[$user1->id]->overridetype); // User2 and user3 have the 'group' override and their 'timeopen' date has been updated. $this->assertEquals($grouptimeopen, $users[$user2->id]->timeopen); $this->assertEquals('group', $users[$user2->id]->overridetype); $this->assertEquals($grouptimeopen, $users[$user3->id]->timeopen); $this->assertEquals('group', $users[$user3->id]->overridetype); // User4 is unchanged. $this->assertEquals($timeopen, $users[$user4->id]->timeopen); $this->assertEquals('none', $users[$user4->id]->overridetype); // User5 should not be in the returned users because they are a teacher. $this->assertArrayNotHasKey($user5->id, $users); // User6 should not be in the returned users because it is suspended. $this->assertArrayNotHasKey($user6->id, $users); // User7 should not be in the returned users because it is suspended within the course. $this->assertArrayNotHasKey($user7->id, $users); // Let's add some availability conditions. $availability = [ 'op' => '&', 'showc' => [true], 'c' => [ [ 'type' => 'group', 'id' => (int)$group->id, ], ], ]; $cm = get_coursemodule_from_instance('quiz', $quiz->id, $course->id); $DB->set_field('course_modules', 'availability', json_encode($availability), ['id' => $cm->id]); // Rebuild course cache to apply changes. rebuild_course_cache($course->id, true); // Get the users after availability conditions of the given quiz. $users = notification_helper::get_users_within_quiz($quiz->id); // Returns only users matching availability conditions who are in the specified group. $this->assertCount(2, $users); ksort($users); $this->assertEquals([$user2->id, $user3->id], array_keys($users)); } /** * Test sending the quiz open soon notification to a user. */ public function test_send_notification_to_user(): void { global $DB; $this->resetAfterTest(); $generator = $this->getDataGenerator(); $helper = \core\di::get(notification_helper::class); $clock = $this->mock_clock_with_frozen(); $sink = $this->redirectMessages(); // Create a course and enrol a user. $course = $generator->create_course(); $user1 = $generator->create_user(); $generator->enrol_user($user1->id, $course->id, 'student'); /** @var \mod_quiz_generator $quizgenerator */ $quizgenerator = $generator->get_plugin_generator('mod_quiz'); // Create a quiz with an open date < 48 hours. $timeopen = $clock->time() + DAYSECS; $quiz = $quizgenerator->create_instance([ 'course' => $course->id, 'timeopen' => $timeopen, ]); $clock->bump(5); // Get the users within the date range. $quizzes = $helper::get_quizzes_within_date_range(); foreach ($quizzes as $q) { $users = $helper::get_users_within_quiz($q->id); } $quizzes->close(); // Run the tasks. $this->run_notification_helper_tasks(); // Get the notifications that should have been created during the adhoc task. $this->assertCount(1, $sink->get_messages()); // Check the subject matches. $messages = $sink->get_messages_by_component('mod_quiz'); $message = reset($messages); $stringparams = ['timeopen' => userdate($users[$user1->id]->timeopen), 'quizname' => $quiz->name]; $expectedsubject = get_string('quizopendatesoonsubject', 'mod_quiz', $stringparams); $this->assertEquals($expectedsubject, $message->subject); // Clear sink. $sink->clear(); // Run the tasks again. $this->run_notification_helper_tasks(); // There should be no notification because nothing has changed. $this->assertEmpty($sink->get_messages_by_component('mod_quiz')); // Let's modify the 'timeopen' for the quiz (it will still be within the 48 hour range). $updatedata = new \stdClass(); $updatedata->id = $quiz->id; $updatedata->timeopen = $timeopen + HOURSECS; $DB->update_record('quiz', $updatedata); // Run the tasks again. $this->run_notification_helper_tasks(); // There should be a new notification because the 'timeopen' has been updated. $this->assertCount(1, $sink->get_messages_by_component('mod_quiz')); // Clear sink. $sink->clear(); // Let's modify the 'timeopen' one more time and change the visibility. $cm = get_coursemodule_from_instance('quiz', $quiz->id, $course->id); $DB->set_field('course_modules', 'visible', 0, ['id' => $cm->id]); $updatedata = new \stdClass(); $updatedata->id = $quiz->id; $updatedata->timeopen = $timeopen + DAYSECS; $DB->update_record('quiz', $updatedata); // Run the tasks again. $this->run_notification_helper_tasks(); // There should not be a new notification because the quiz is not visible. $this->assertCount(0, $sink->get_messages_by_component('mod_quiz')); // Set back the visibility. $DB->set_field('course_modules', 'visible', 1, ['id' => $cm->id]); // Clear sink. $sink->clear(); // Let's modify the 'timeopen' one more time. $updatedata = new \stdClass(); $updatedata->id = $quiz->id; $updatedata->timeopen = $timeopen + (HOURSECS * 2); $DB->update_record('quiz', $updatedata); // This time, the user will submit an attempt. $DB->insert_record('quiz_attempts', [ 'quiz' => $quiz->id, 'userid' => $user1->id, 'state' => 'finished', 'timestart' => $clock->time(), 'timecheckstate' => 0, 'layout' => '', 'uniqueid' => 123, ]); $clock->bump(5); // Run the tasks again. $this->run_notification_helper_tasks(); // No new notification should have been sent. $this->assertEmpty($sink->get_messages_by_component('mod_quiz')); // Clear sink. $sink->clear(); } /** * Test content filtering in the quiz open soon notification. */ public function test_send_notification_to_user_filter_content(): void { $this->resetAfterTest(); $generator = $this->getDataGenerator(); $clock = $this->mock_clock_with_frozen(); $sink = $this->redirectMessages(); filter_set_global_state('multilang', TEXTFILTER_ON); filter_set_applies_to_strings('multilang', true); // Create a course and enrol a student. $course = $generator->create_course([ 'fullname' => 'A&B (en)A&B (es)' ]); $student = $generator->create_user(); $generator->enrol_user($student->id, $course->id, 'student'); $quizgenerator = $generator->get_plugin_generator('mod_quiz'); // Create a quiz with an open date < 48 hours. $quiz = $quizgenerator->create_instance([ 'name' => 'C&D (en)C&D (es)', 'course' => $course->id, 'timeopen' => $clock->time() + DAYSECS, ]); $clock->bump(5); // Run the tasks. $this->run_notification_helper_tasks(); // Get the notifications that should have been created during the adhoc task. $messages = $sink->get_messages_by_component('mod_quiz'); $message = reset($messages); $subjectstringparams = [ 'timeopen' => userdate($quiz->timeopen), 'quizname' => 'C&D (en)' ]; $expectedsubject = get_string('quizopendatesoonsubject', 'mod_quiz', $subjectstringparams); $fullmessagestringparams = array_merge($subjectstringparams, [ 'firstname' => $student->firstname, 'coursename' => 'A&B (en)', 'timeclose' => !empty($quiz->timeclose) ? userdate($quiz->timeclose) : get_string('statusna'), 'url' => new \moodle_url('/mod/quiz/view.php', ['id' => $quiz->cmid]) ]); $expectedfullmessage = get_string('quizopendatesoonhtml', 'mod_quiz', $fullmessagestringparams); // Validate $this->assertEquals($expectedsubject, $message->subject); $this->assertEquals($expectedfullmessage, $message->fullmessagehtml); // Clear sink. $sink->clear(); } } PK9B\01((tests/event/events_test.phpnu[. /** * Quiz events tests. * * @package mod_quiz * @category phpunit * @copyright 2013 Adrian Greeve * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace mod_quiz\event; use mod_quiz\quiz_attempt; use mod_quiz\quiz_settings; use context_module; use mod_quiz\external\submit_question_version; /** * Unit tests for quiz events. * * @package mod_quiz * @category phpunit * @copyright 2013 Adrian Greeve * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @covers \mod_quiz\external\submit_question_version */ final class events_test extends \advanced_testcase { /** * Set up a quiz. * * The quiz contains two questions, a short-answer one and a numerical one. * * @return quiz_settings the generated quiz. */ protected function prepare_quiz() { $this->resetAfterTest(true); // Create a course $course = $this->getDataGenerator()->create_course(); // Make a quiz. $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz'); $quiz = $quizgenerator->create_instance(['course' => $course->id, 'questionsperpage' => 0, 'grade' => 100.0, 'sumgrades' => 2]); get_coursemodule_from_instance('quiz', $quiz->id, $course->id); // Create a couple of questions. $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); $cat = $questiongenerator->create_question_category(); $saq = $questiongenerator->create_question('shortanswer', null, ['category' => $cat->id]); $numq = $questiongenerator->create_question('numerical', null, ['category' => $cat->id]); // Update the numq question so it has multiple versions. Needed to test // the version updated event. $questiongenerator->update_question($numq, null, ['name' => 'Second version of numq']); // Add them to the quiz. quiz_add_quiz_question($saq->id, $quiz); quiz_add_quiz_question($numq->id, $quiz); // Make a user to do the quiz. $user1 = $this->getDataGenerator()->create_user(); $this->setUser($user1); return quiz_settings::create($quiz->id, $user1->id); } /** * Setup a quiz attempt at the quiz created by {@link prepare_quiz()}. * * @param \mod_quiz\quiz_settings $quizobj the generated quiz. * @param bool $ispreview Make the attempt a preview attempt when true. * @return array with three elements, array($quizobj, $quba, $attempt) */ protected function prepare_quiz_attempt($quizobj, $ispreview = false) { // Start the attempt. $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, $ispreview); quiz_start_new_attempt($quizobj, $quba, $attempt, 1, $timenow); quiz_attempt_save_started($quizobj, $quba, $attempt); return [$quizobj, $quba, $attempt]; } /** * Setup some convenience test data with a single attempt. * * @param bool $ispreview Make the attempt a preview attempt when true. * @return array with three elements, array($quizobj, $quba, $attempt) */ protected function prepare_quiz_data($ispreview = false) { $quizobj = $this->prepare_quiz(); return $this->prepare_quiz_attempt($quizobj, $ispreview); } public function test_attempt_submitted(): void { [$quizobj, , $attempt] = $this->prepare_quiz_data(); $attemptobj = quiz_attempt::create($attempt->id); // Catch the event. $sink = $this->redirectEvents(); $timefinish = time(); $attemptobj->process_submit($timefinish, false); $events = $sink->get_events(); $sink->close(); // Validate the event. $this->assertCount(1, $events); $event = $events[0]; $this->assertInstanceOf('\mod_quiz\event\attempt_submitted', $event); $this->assertEquals('quiz_attempts', $event->objecttable); $this->assertEquals($quizobj->get_context(), $event->get_context()); $this->assertEquals($attempt->userid, $event->relateduserid); $this->assertEquals(null, $event->other['submitterid']); // Should be the user, but PHP Unit complains... $this->assertEventContextNotUsed($event); } /** * The \mod_quiz\event\attempt_graded event should be fired when an attempt is graded. * * @return void * @covers \mod_quiz\quiz_attempt::process_grade_submission */ public function test_attempt_graded(): void { [$quizobj, , $attempt] = $this->prepare_quiz_data(); $attemptobj = quiz_attempt::create($attempt->id); $timefinish = time(); $attemptobj->process_submit($timefinish, false); // Catch the event. $sink = $this->redirectEvents(); $attemptobj->process_grade_submission($timefinish); $events = $sink->get_events(); $sink->close(); // Validate the event. $this->assertCount(3, $events); $event = $events[2]; $this->assertInstanceOf('\mod_quiz\event\attempt_graded', $event); $this->assertEquals('quiz_attempts', $event->objecttable); $this->assertEquals($quizobj->get_context(), $event->get_context()); $this->assertEquals($attempt->userid, $event->relateduserid); $this->assertEquals(null, $event->other['submitterid']); // Should be the user, but PHP Unit complains... $this->assertEventContextNotUsed($event); } public function test_attempt_becameoverdue(): void { list($quizobj, $quba, $attempt) = $this->prepare_quiz_data(); $attemptobj = quiz_attempt::create($attempt->id); // Catch the event. $sink = $this->redirectEvents(); $timefinish = time(); $attemptobj->process_going_overdue($timefinish, false); $events = $sink->get_events(); $sink->close(); $this->assertCount(1, $events); $event = $events[0]; $this->assertInstanceOf('\mod_quiz\event\attempt_becameoverdue', $event); $this->assertEquals('quiz_attempts', $event->objecttable); $this->assertEquals($quizobj->get_context(), $event->get_context()); $this->assertEquals($attempt->userid, $event->relateduserid); $this->assertNotEmpty($event->get_description()); // Submitterid should be the user, but as we are in PHP Unit, CLI_SCRIPT is set to true which sets null in submitterid. $this->assertEquals(null, $event->other['submitterid']); $this->assertEventContextNotUsed($event); } public function test_attempt_abandoned(): void { list($quizobj, $quba, $attempt) = $this->prepare_quiz_data(); $attemptobj = quiz_attempt::create($attempt->id); // Catch the event. $sink = $this->redirectEvents(); $timefinish = time(); $attemptobj->process_abandon($timefinish, false); $events = $sink->get_events(); $sink->close(); $this->assertCount(1, $events); $event = $events[0]; $this->assertInstanceOf('\mod_quiz\event\attempt_abandoned', $event); $this->assertEquals('quiz_attempts', $event->objecttable); $this->assertEquals($quizobj->get_context(), $event->get_context()); $this->assertEquals($attempt->userid, $event->relateduserid); // Submitterid should be the user, but as we are in PHP Unit, CLI_SCRIPT is set to true which sets null in submitterid. $this->assertEquals(null, $event->other['submitterid']); $this->assertEventContextNotUsed($event); } public function test_attempt_started(): void { $quizobj = $this->prepare_quiz(); $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); quiz_start_new_attempt($quizobj, $quba, $attempt, 1, $timenow); // Trigger and capture the event. $sink = $this->redirectEvents(); quiz_attempt_save_started($quizobj, $quba, $attempt); $events = $sink->get_events(); $event = reset($events); // Check that the event data is valid. $this->assertInstanceOf('\mod_quiz\event\attempt_started', $event); $this->assertEquals('quiz_attempts', $event->objecttable); $this->assertEquals($attempt->id, $event->objectid); $this->assertEquals($attempt->userid, $event->relateduserid); $this->assertEquals($quizobj->get_context(), $event->get_context()); $this->assertEquals(\context_module::instance($quizobj->get_cmid()), $event->get_context()); } /** * Test the attempt question restarted event. * * There is no external API for replacing a question, so the unit test will simply * create and trigger the event and ensure the event data is returned as expected. */ public function test_attempt_question_restarted(): void { list($quizobj, $quba, $attempt) = $this->prepare_quiz_data(); $params = [ 'objectid' => 1, 'relateduserid' => 2, 'courseid' => $quizobj->get_courseid(), 'context' => \context_module::instance($quizobj->get_cmid()), 'other' => [ 'quizid' => $quizobj->get_quizid(), 'page' => 2, 'slot' => 3, 'newquestionid' => 2 ] ]; $event = \mod_quiz\event\attempt_question_restarted::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('\mod_quiz\event\attempt_question_restarted', $event); $this->assertEquals(\context_module::instance($quizobj->get_cmid()), $event->get_context()); $this->assertEventContextNotUsed($event); } /** * Test the attempt updated event. * * There is no external API for updating an attempt, so the unit test will simply * create and trigger the event and ensure the event data is returned as expected. */ public function test_attempt_updated(): void { list($quizobj, $quba, $attempt) = $this->prepare_quiz_data(); $params = [ 'objectid' => 1, 'relateduserid' => 2, 'courseid' => $quizobj->get_courseid(), 'context' => \context_module::instance($quizobj->get_cmid()), 'other' => [ 'quizid' => $quizobj->get_quizid(), 'page' => 0 ] ]; $event = \mod_quiz\event\attempt_updated::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('\mod_quiz\event\attempt_updated', $event); $this->assertEquals(\context_module::instance($quizobj->get_cmid()), $event->get_context()); $this->assertEventContextNotUsed($event); } /** * Test the attempt auto-saved event. * * There is no external API for auto-saving an attempt, so the unit test will simply * create and trigger the event and ensure the event data is returned as expected. */ public function test_attempt_autosaved(): void { list($quizobj, $quba, $attempt) = $this->prepare_quiz_data(); $params = [ 'objectid' => 1, 'relateduserid' => 2, 'courseid' => $quizobj->get_courseid(), 'context' => \context_module::instance($quizobj->get_cmid()), 'other' => [ 'quizid' => $quizobj->get_quizid(), 'page' => 0 ] ]; $event = \mod_quiz\event\attempt_autosaved::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('\mod_quiz\event\attempt_autosaved', $event); $this->assertEquals(\context_module::instance($quizobj->get_cmid()), $event->get_context()); $this->assertEventContextNotUsed($event); } /** * Test the edit page viewed event. * * There is no external API for updating a quiz, so the unit test will simply * create and trigger the event and ensure the event data is returned as expected. */ public function test_edit_page_viewed(): void { $this->resetAfterTest(); $this->setAdminUser(); $course = $this->getDataGenerator()->create_course(); $quiz = $this->getDataGenerator()->create_module('quiz', ['course' => $course->id]); $params = [ 'courseid' => $course->id, 'context' => \context_module::instance($quiz->cmid), 'other' => [ 'quizid' => $quiz->id ] ]; $event = \mod_quiz\event\edit_page_viewed::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('\mod_quiz\event\edit_page_viewed', $event); $this->assertEquals(\context_module::instance($quiz->cmid), $event->get_context()); $this->assertEventContextNotUsed($event); } /** * Test the attempt deleted event. */ public function test_attempt_deleted(): void { list($quizobj, $quba, $attempt) = $this->prepare_quiz_data(); // Trigger and capture the event. $sink = $this->redirectEvents(); quiz_delete_attempt($attempt, $quizobj->get_quiz()); $events = $sink->get_events(); $event = reset($events); // Check that the event data is valid. $this->assertInstanceOf('\mod_quiz\event\attempt_deleted', $event); $this->assertEquals(\context_module::instance($quizobj->get_cmid()), $event->get_context()); $this->assertEventContextNotUsed($event); } /** * Test that preview attempt deletions are not logged. */ public function test_preview_attempt_deleted(): void { // Create quiz with preview attempt. list($quizobj, $quba, $previewattempt) = $this->prepare_quiz_data(true); // Delete a preview attempt, capturing events. $sink = $this->redirectEvents(); quiz_delete_attempt($previewattempt, $quizobj->get_quiz()); // Verify that no events were generated. $this->assertEmpty($sink->get_events()); } /** * Test the report viewed event. * * There is no external API for viewing reports, so the unit test will simply * create and trigger the event and ensure the event data is returned as expected. */ public function test_report_viewed(): void { $this->resetAfterTest(); $this->setAdminUser(); $course = $this->getDataGenerator()->create_course(); $quiz = $this->getDataGenerator()->create_module('quiz', ['course' => $course->id]); $params = [ 'context' => $context = \context_module::instance($quiz->cmid), 'other' => [ 'quizid' => $quiz->id, 'reportname' => 'overview' ] ]; $event = \mod_quiz\event\report_viewed::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('\mod_quiz\event\report_viewed', $event); $this->assertEquals(\context_module::instance($quiz->cmid), $event->get_context()); $this->assertEventContextNotUsed($event); } /** * Test the attempt reviewed event. * * There is no external API for reviewing attempts, so the unit test will simply * create and trigger the event and ensure the event data is returned as expected. */ public function test_attempt_reviewed(): void { $this->resetAfterTest(); $this->setAdminUser(); $course = $this->getDataGenerator()->create_course(); $quiz = $this->getDataGenerator()->create_module('quiz', ['course' => $course->id]); $params = [ 'objectid' => 1, 'relateduserid' => 2, 'courseid' => $course->id, 'context' => \context_module::instance($quiz->cmid), 'other' => [ 'quizid' => $quiz->id ] ]; $event = \mod_quiz\event\attempt_reviewed::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('\mod_quiz\event\attempt_reviewed', $event); $this->assertEquals(\context_module::instance($quiz->cmid), $event->get_context()); $this->assertEventContextNotUsed($event); } /** * Test the attempt summary viewed event. * * There is no external API for viewing the attempt summary, so the unit test will simply * create and trigger the event and ensure the event data is returned as expected. */ public function test_attempt_summary_viewed(): void { $this->resetAfterTest(); $this->setAdminUser(); $course = $this->getDataGenerator()->create_course(); $quiz = $this->getDataGenerator()->create_module('quiz', ['course' => $course->id]); $params = [ 'objectid' => 1, 'relateduserid' => 2, 'courseid' => $course->id, 'context' => \context_module::instance($quiz->cmid), 'other' => [ 'quizid' => $quiz->id ] ]; $event = \mod_quiz\event\attempt_summary_viewed::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('\mod_quiz\event\attempt_summary_viewed', $event); $this->assertEquals(\context_module::instance($quiz->cmid), $event->get_context()); $this->assertEventContextNotUsed($event); } /** * Test the user override created event. * * There is no external API for creating a user override, so the unit test will simply * create and trigger the event and ensure the event data is returned as expected. */ public function test_user_override_created(): void { $this->resetAfterTest(); $this->setAdminUser(); $course = $this->getDataGenerator()->create_course(); $quiz = $this->getDataGenerator()->create_module('quiz', ['course' => $course->id]); $params = [ 'objectid' => 1, 'relateduserid' => 2, 'context' => \context_module::instance($quiz->cmid), 'other' => [ 'quizid' => $quiz->id ] ]; $event = \mod_quiz\event\user_override_created::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('\mod_quiz\event\user_override_created', $event); $this->assertEquals(\context_module::instance($quiz->cmid), $event->get_context()); $this->assertEventContextNotUsed($event); } /** * Test the group override created event. * * There is no external API for creating a group override, so the unit test will simply * create and trigger the event and ensure the event data is returned as expected. */ public function test_group_override_created(): void { $this->resetAfterTest(); $this->setAdminUser(); $course = $this->getDataGenerator()->create_course(); $quiz = $this->getDataGenerator()->create_module('quiz', ['course' => $course->id]); $params = [ 'objectid' => 1, 'context' => \context_module::instance($quiz->cmid), 'other' => [ 'quizid' => $quiz->id, 'groupid' => 2 ] ]; $event = \mod_quiz\event\group_override_created::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('\mod_quiz\event\group_override_created', $event); $this->assertEquals(\context_module::instance($quiz->cmid), $event->get_context()); $this->assertEventContextNotUsed($event); } /** * Test the user override updated event. * * There is no external API for updating a user override, so the unit test will simply * create and trigger the event and ensure the event data is returned as expected. */ public function test_user_override_updated(): void { $this->resetAfterTest(); $this->setAdminUser(); $course = $this->getDataGenerator()->create_course(); $quiz = $this->getDataGenerator()->create_module('quiz', ['course' => $course->id]); $params = [ 'objectid' => 1, 'relateduserid' => 2, 'context' => \context_module::instance($quiz->cmid), 'other' => [ 'quizid' => $quiz->id ] ]; $event = \mod_quiz\event\user_override_updated::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('\mod_quiz\event\user_override_updated', $event); $this->assertEquals(\context_module::instance($quiz->cmid), $event->get_context()); $this->assertEventContextNotUsed($event); } /** * Test the group override updated event. * * There is no external API for updating a group override, so the unit test will simply * create and trigger the event and ensure the event data is returned as expected. */ public function test_group_override_updated(): void { $this->resetAfterTest(); $this->setAdminUser(); $course = $this->getDataGenerator()->create_course(); $quiz = $this->getDataGenerator()->create_module('quiz', ['course' => $course->id]); $params = [ 'objectid' => 1, 'context' => \context_module::instance($quiz->cmid), 'other' => [ 'quizid' => $quiz->id, 'groupid' => 2 ] ]; $event = \mod_quiz\event\group_override_updated::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('\mod_quiz\event\group_override_updated', $event); $this->assertEquals(\context_module::instance($quiz->cmid), $event->get_context()); $this->assertEventContextNotUsed($event); } /** * Test the user override deleted event. */ public function test_user_override_deleted(): void { global $DB; $this->resetAfterTest(); $this->setAdminUser(); $course = $this->getDataGenerator()->create_course(); $quiz = $this->getDataGenerator()->create_module('quiz', ['course' => $course->id]); $quizsettings = quiz_settings::create($quiz->id); // Create an override. $override = new \stdClass(); $override->quiz = $quiz->id; $override->userid = 2; $override->id = $DB->insert_record('quiz_overrides', $override); // Trigger and capture the event. $sink = $this->redirectEvents(); $quizsettings->get_override_manager()->delete_overrides(overrides: [$override]); $events = $sink->get_events(); $event = reset($events); // Check that the event data is valid. $this->assertInstanceOf('\mod_quiz\event\user_override_deleted', $event); $this->assertEquals(\context_module::instance($quiz->cmid), $event->get_context()); $this->assertEventContextNotUsed($event); } /** * Test the group override deleted event. */ public function test_group_override_deleted(): void { global $DB; $this->resetAfterTest(); $this->setAdminUser(); $course = $this->getDataGenerator()->create_course(); $quiz = $this->getDataGenerator()->create_module('quiz', ['course' => $course->id]); $quizsettings = quiz_settings::create($quiz->id); // Create an override. $override = new \stdClass(); $override->quiz = $quiz->id; $override->groupid = 2; $override->id = $DB->insert_record('quiz_overrides', $override); // Trigger and capture the event. $sink = $this->redirectEvents(); $quizsettings->get_override_manager()->delete_overrides(overrides: [$override]); $events = $sink->get_events(); $event = reset($events); // Check that the event data is valid. $this->assertInstanceOf('\mod_quiz\event\group_override_deleted', $event); $this->assertEquals(\context_module::instance($quiz->cmid), $event->get_context()); $this->assertEventContextNotUsed($event); } /** * Test the attempt viewed event. * * There is no external API for continuing an attempt, so the unit test will simply * create and trigger the event and ensure the event data is returned as expected. */ public function test_attempt_viewed(): void { $this->resetAfterTest(); $this->setAdminUser(); $course = $this->getDataGenerator()->create_course(); $quiz = $this->getDataGenerator()->create_module('quiz', ['course' => $course->id]); $params = [ 'objectid' => 1, 'relateduserid' => 2, 'courseid' => $course->id, 'context' => \context_module::instance($quiz->cmid), 'other' => [ 'quizid' => $quiz->id, 'page' => 0 ] ]; $event = \mod_quiz\event\attempt_viewed::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('\mod_quiz\event\attempt_viewed', $event); $this->assertEquals(\context_module::instance($quiz->cmid), $event->get_context()); $this->assertEventContextNotUsed($event); } /** * Test the attempt previewed event. */ public function test_attempt_preview_started(): void { $quizobj = $this->prepare_quiz(); $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, true); quiz_start_new_attempt($quizobj, $quba, $attempt, 1, $timenow); // Trigger and capture the event. $sink = $this->redirectEvents(); quiz_attempt_save_started($quizobj, $quba, $attempt); $events = $sink->get_events(); $event = reset($events); // Check that the event data is valid. $this->assertInstanceOf('\mod_quiz\event\attempt_preview_started', $event); $this->assertEquals(\context_module::instance($quizobj->get_cmid()), $event->get_context()); $this->assertEventContextNotUsed($event); } /** * Test the question manually graded event. * * There is no external API for manually grading a question, so the unit test will simply * create and trigger the event and ensure the event data is returned as expected. */ public function test_question_manually_graded(): void { list($quizobj, $quba, $attempt) = $this->prepare_quiz_data(); $params = [ 'objectid' => 1, 'courseid' => $quizobj->get_courseid(), 'context' => \context_module::instance($quizobj->get_cmid()), 'other' => [ 'quizid' => $quizobj->get_quizid(), 'attemptid' => 2, 'slot' => 3 ] ]; $event = \mod_quiz\event\question_manually_graded::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('\mod_quiz\event\question_manually_graded', $event); $this->assertEquals(\context_module::instance($quizobj->get_cmid()), $event->get_context()); $this->assertEventContextNotUsed($event); } /** * Test the attempt regraded event. * * There is no external API for regrading attempts, so the unit test will simply * create and trigger the event and ensure the event data is returned as expected. */ public function test_attempt_regraded(): void { $this->resetAfterTest(); $this->setAdminUser(); $course = $this->getDataGenerator()->create_course(); $quiz = $this->getDataGenerator()->create_module('quiz', ['course' => $course->id]); $params = [ 'objectid' => 1, 'relateduserid' => 2, 'courseid' => $course->id, 'context' => \context_module::instance($quiz->cmid), 'other' => [ 'quizid' => $quiz->id ] ]; $event = \mod_quiz\event\attempt_regraded::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('\mod_quiz\event\attempt_regraded', $event); $this->assertEquals(\context_module::instance($quiz->cmid), $event->get_context()); $this->assertEventContextNotUsed($event); } /** * Test the attempt notify manual graded event. * There is no external API for notification email when manual grading of user's attempt is completed, * so the unit test will simply create and trigger the event and ensure the event data is returned as expected. */ public function test_attempt_manual_grading_completed(): void { $this->resetAfterTest(); list($quizobj, $quba, $attempt) = $this->prepare_quiz_data(); $attemptobj = quiz_attempt::create($attempt->id); $params = [ 'objectid' => $attemptobj->get_attemptid(), 'relateduserid' => $attemptobj->get_userid(), 'courseid' => $attemptobj->get_course()->id, 'context' => \context_module::instance($attemptobj->get_cmid()), 'other' => [ 'quizid' => $attemptobj->get_quizid() ] ]; $event = \mod_quiz\event\attempt_manual_grading_completed::create($params); // Catch the event. $sink = $this->redirectEvents(); $event->trigger(); $events = $sink->get_events(); $sink->close(); // Validate the event. $this->assertCount(1, $events); $event = reset($events); $this->assertInstanceOf('\mod_quiz\event\attempt_manual_grading_completed', $event); $this->assertEquals('quiz_attempts', $event->objecttable); $this->assertEquals($quizobj->get_context(), $event->get_context()); $this->assertEquals($attempt->userid, $event->relateduserid); $this->assertNotEmpty($event->get_description()); $this->assertEventContextNotUsed($event); } /** * Test the page break created event. * * There is no external API for creating page break, so the unit test will simply * create and trigger the event and ensure the event data is returned as expected. */ public function test_page_break_created(): void { $quizobj = $this->prepare_quiz(); $params = [ 'objectid' => 1, 'context' => context_module::instance($quizobj->get_cmid()), 'other' => [ 'quizid' => $quizobj->get_quizid(), 'slotnumber' => 3, ] ]; $event = \mod_quiz\event\page_break_created::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('\mod_quiz\event\page_break_created', $event); $this->assertEquals(context_module::instance($quizobj->get_cmid()), $event->get_context()); $this->assertEventContextNotUsed($event); } /** * Test the page break deleted event. * * There is no external API for deleting page break, so the unit test will simply * create and trigger the event and ensure the event data is returned as expected. */ public function test_page_deleted_created(): void { $quizobj = $this->prepare_quiz(); $params = [ 'objectid' => 1, 'context' => context_module::instance($quizobj->get_cmid()), 'other' => [ 'quizid' => $quizobj->get_quizid(), 'slotnumber' => 3, ] ]; $event = \mod_quiz\event\page_break_deleted::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('\mod_quiz\event\page_break_deleted', $event); $this->assertEquals(context_module::instance($quizobj->get_cmid()), $event->get_context()); $this->assertEventContextNotUsed($event); } /** * Test the quiz grade updated event. * * There is no external API for updating quiz grade, so the unit test will simply * create and trigger the event and ensure the event data is returned as expected. */ public function test_quiz_grade_updated(): void { $quizobj = $this->prepare_quiz(); $params = [ 'objectid' => $quizobj->get_quizid(), 'context' => context_module::instance($quizobj->get_cmid()), 'other' => [ 'oldgrade' => 1, 'newgrade' => 3, ] ]; $event = \mod_quiz\event\quiz_grade_updated::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('\mod_quiz\event\quiz_grade_updated', $event); $this->assertEquals(context_module::instance($quizobj->get_cmid()), $event->get_context()); $this->assertEventContextNotUsed($event); } /** * Test the quiz re-paginated event. * * There is no external API for re-paginating quiz, so the unit test will simply * create and trigger the event and ensure the event data is returned as expected. */ public function test_quiz_repaginated(): void { $quizobj = $this->prepare_quiz(); $params = [ 'objectid' => $quizobj->get_quizid(), 'context' => context_module::instance($quizobj->get_cmid()), 'other' => [ 'slotsperpage' => 3, ] ]; $event = \mod_quiz\event\quiz_repaginated::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('\mod_quiz\event\quiz_repaginated', $event); $this->assertEquals(context_module::instance($quizobj->get_cmid()), $event->get_context()); $this->assertEventContextNotUsed($event); } /** * Test the section break created event. * * There is no external API for creating section break, so the unit test will simply * create and trigger the event and ensure the event data is returned as expected. */ public function test_section_break_created(): void { $quizobj = $this->prepare_quiz(); $params = [ 'objectid' => 1, 'context' => context_module::instance($quizobj->get_cmid()), 'other' => [ 'quizid' => $quizobj->get_quizid(), 'firstslotid' => 1, 'firstslotnumber' => 2, 'title' => 'New title' ] ]; $event = \mod_quiz\event\section_break_created::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('\mod_quiz\event\section_break_created', $event); $this->assertEquals(context_module::instance($quizobj->get_cmid()), $event->get_context()); $this->assertStringContainsString($params['other']['title'], $event->get_description()); $this->assertEventContextNotUsed($event); } /** * Test the section break deleted event. * * There is no external API for deleting section break, so the unit test will simply * create and trigger the event and ensure the event data is returned as expected. */ public function test_section_break_deleted(): void { $quizobj = $this->prepare_quiz(); $params = [ 'objectid' => 1, 'context' => context_module::instance($quizobj->get_cmid()), 'other' => [ 'quizid' => $quizobj->get_quizid(), 'firstslotid' => 1, 'firstslotnumber' => 2 ] ]; $event = \mod_quiz\event\section_break_deleted::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('\mod_quiz\event\section_break_deleted', $event); $this->assertEquals(context_module::instance($quizobj->get_cmid()), $event->get_context()); $this->assertEventContextNotUsed($event); } /** * Test the section shuffle updated event. * * There is no external API for updating section shuffle, so the unit test will simply * create and trigger the event and ensure the event data is returned as expected. */ public function test_section_shuffle_updated(): void { $quizobj = $this->prepare_quiz(); $params = [ 'objectid' => 1, 'context' => context_module::instance($quizobj->get_cmid()), 'other' => [ 'quizid' => $quizobj->get_quizid(), 'firstslotnumber' => 2, 'shuffle' => true ] ]; $event = \mod_quiz\event\section_shuffle_updated::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('\mod_quiz\event\section_shuffle_updated', $event); $this->assertEquals(context_module::instance($quizobj->get_cmid()), $event->get_context()); $this->assertEventContextNotUsed($event); } /** * Test the section title updated event. * * There is no external API for updating section title, so the unit test will simply * create and trigger the event and ensure the event data is returned as expected. */ public function test_section_title_updated(): void { $quizobj = $this->prepare_quiz(); $params = [ 'objectid' => 1, 'context' => context_module::instance($quizobj->get_cmid()), 'other' => [ 'quizid' => $quizobj->get_quizid(), 'firstslotid' => 1, 'firstslotnumber' => 2, 'newtitle' => 'New title' ] ]; $event = \mod_quiz\event\section_title_updated::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('\mod_quiz\event\section_title_updated', $event); $this->assertEquals(context_module::instance($quizobj->get_cmid()), $event->get_context()); $this->assertStringContainsString($params['other']['newtitle'], $event->get_description()); $this->assertEventContextNotUsed($event); } /** * Test the slot created event. * * There is no external API for creating slot, so the unit test will simply * create and trigger the event and ensure the event data is returned as expected. */ public function test_slot_created(): void { $quizobj = $this->prepare_quiz(); $params = [ 'objectid' => 1, 'context' => context_module::instance($quizobj->get_cmid()), 'other' => [ 'quizid' => $quizobj->get_quizid(), 'slotnumber' => 1, 'page' => 1 ] ]; $event = \mod_quiz\event\slot_created::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('\mod_quiz\event\slot_created', $event); $this->assertEquals(context_module::instance($quizobj->get_cmid()), $event->get_context()); $this->assertEventContextNotUsed($event); } /** * Test the slot deleted event. * * There is no external API for deleting slot, so the unit test will simply * create and trigger the event and ensure the event data is returned as expected. */ public function test_slot_deleted(): void { $quizobj = $this->prepare_quiz(); $params = [ 'objectid' => 1, 'context' => context_module::instance($quizobj->get_cmid()), 'other' => [ 'quizid' => $quizobj->get_quizid(), 'slotnumber' => 1, ] ]; $event = \mod_quiz\event\slot_deleted::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('\mod_quiz\event\slot_deleted', $event); $this->assertEquals(context_module::instance($quizobj->get_cmid()), $event->get_context()); $this->assertEventContextNotUsed($event); } /** * Test the slot mark updated event. * * There is no external API for updating slot mark, so the unit test will simply * create and trigger the event and ensure the event data is returned as expected. */ public function test_slot_mark_updated(): void { $quizobj = $this->prepare_quiz(); $params = [ 'objectid' => 1, 'context' => context_module::instance($quizobj->get_cmid()), 'other' => [ 'quizid' => $quizobj->get_quizid(), 'previousmaxmark' => 1, 'newmaxmark' => 2, ] ]; $event = \mod_quiz\event\slot_mark_updated::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('\mod_quiz\event\slot_mark_updated', $event); $this->assertEquals($quizobj->get_context(), $event->get_context()); $this->assertEventContextNotUsed($event); } public function test_slot_version_updated(): void { $quizobj = $this->prepare_quiz(); $this->setAdminUser(); $quizobj->preload_questions(); [, $numqslotid] = array_column($quizobj->get_questions(null, false), 'slotid'); $sink = $this->redirectEvents(); submit_question_version::execute($numqslotid, 2); $events = $sink->get_events(); $event = reset($events); // Check that the event data is valid. $expecteddesc = "The user with id '2' updated the slot with id '$numqslotid' " . "belonging to the quiz with course module id '{$quizobj->get_cmid()}'. " . "Its question version was changed from 'Always latest' to '2'."; $this->assertInstanceOf('\mod_quiz\event\slot_version_updated', $event); $this->assertEquals($quizobj->get_context(), $event->get_context()); $this->assertEquals($expecteddesc, $event->get_description()); $this->assertEventContextNotUsed($event); } /** * Test quiz_grade_item_created. * * @covers \mod_quiz\event\quiz_grade_item_created */ public function test_quiz_grade_item_created(): void { global $USER; $quizobj = $this->prepare_quiz(); $stucture = $quizobj->get_structure(); // Trigger and capture the event. $sink = $this->redirectEvents(); $stucture->create_grade_item((object) ['quizid' => $quizobj->get_quizid(), 'name' => 'Test']); $events = $sink->get_events(); /** @var slot_grade_item_updated $event */ $event = reset($events); // Check that the event data is valid. $stucture = $quizobj->get_structure(); $gradeitem = array_values($stucture->get_grade_items())[0]; $this->assertInstanceOf(quiz_grade_item_created::class, $event); $this->assertEquals($quizobj->get_context(), $event->get_context()); $this->assertEventContextNotUsed($event); $this->assertEquals(new \moodle_url('/mod/quiz/editgrading.php', ['cmid' => $quizobj->get_cmid()]), $event->get_url()); $this->assertEquals("The user with id '$USER->id' created quiz grade item with id '$gradeitem->id' " . "for the quiz with course module id '{$quizobj->get_cmid()}'.", $event->get_description()); } /** * Test quiz_grade_item_updated. * * @covers \mod_quiz\event\quiz_grade_item_updated */ public function test_quiz_grade_item_updated(): void { global $USER; $quizobj = $this->prepare_quiz(); /** @var \mod_quiz_generator $quizgenerator */ $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz'); $gradeitem = $quizgenerator->create_grade_item( ['quizid' => $quizobj->get_quizid(), 'name' => 'Awesomeness!']); $stucture = $quizobj->get_structure(); // Trigger and capture the event. $sink = $this->redirectEvents(); $stucture->update_grade_item((object) ['id' => $gradeitem->id, 'name' => 'Test']); $events = $sink->get_events(); /** @var slot_grade_item_updated $event */ $event = reset($events); // Check that the event data is valid. $this->assertInstanceOf(quiz_grade_item_updated::class, $event); $this->assertEquals($quizobj->get_context(), $event->get_context()); $this->assertEventContextNotUsed($event); $this->assertEquals(new \moodle_url('/mod/quiz/editgrading.php', ['cmid' => $quizobj->get_cmid()]), $event->get_url()); $this->assertEquals("The user with id '$USER->id' updated quiz grade item with id '$gradeitem->id' " . "for the quiz with course module id '{$quizobj->get_cmid()}'.", $event->get_description()); } /** * Test quiz_grade_item_deleted. * * @covers \mod_quiz\event\quiz_grade_item_deleted */ public function test_quiz_grade_item_deleted(): void { global $USER; $quizobj = $this->prepare_quiz(); /** @var \mod_quiz_generator $quizgenerator */ $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz'); $gradeitem = $quizgenerator->create_grade_item( ['quizid' => $quizobj->get_quizid(), 'name' => 'Awesomeness!']); $stucture = $quizobj->get_structure(); // Trigger and capture the event. $sink = $this->redirectEvents(); $stucture->delete_grade_item($gradeitem->id); $events = $sink->get_events(); /** @var slot_grade_item_updated $event */ $event = reset($events); // Check that the event data is valid. $this->assertInstanceOf(quiz_grade_item_deleted::class, $event); $this->assertEquals($quizobj->get_context(), $event->get_context()); $this->assertEventContextNotUsed($event); $this->assertEquals(new \moodle_url('/mod/quiz/editgrading.php', ['cmid' => $quizobj->get_cmid()]), $event->get_url()); $this->assertEquals("The user with id '$USER->id' deleted quiz grade item with id '$gradeitem->id' " . "for the quiz with course module id '{$quizobj->get_cmid()}'.", $event->get_description()); } /** * Test slot_grade_item_updated. * * @covers \mod_quiz\event\slot_grade_item_updated */ public function test_slot_grade_item_updated(): void { global $USER; $quizobj = $this->prepare_quiz(); /** @var \mod_quiz_generator $quizgenerator */ $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz'); $gradeitem = $quizgenerator->create_grade_item( ['quizid' => $quizobj->get_quizid(), 'name' => 'Awesomeness!']); $stucture = $quizobj->get_structure(); $slot = $stucture->get_slot_by_number(1); // Trigger and capture the event. $sink = $this->redirectEvents(); $stucture->update_slot_grade_item($slot, $gradeitem->id); $events = $sink->get_events(); /** @var slot_grade_item_updated $event */ $event = reset($events); // Check that the event data is valid. $this->assertInstanceOf(slot_grade_item_updated::class, $event); $this->assertEquals($quizobj->get_context(), $event->get_context()); $this->assertEventContextNotUsed($event); $this->assertEquals(new \moodle_url('/mod/quiz/editgrading.php', ['cmid' => $quizobj->get_cmid()]), $event->get_url()); $this->assertEquals("The user with id '$USER->id' updated the slot with id '{$slot->id}' " . "belonging to the quiz with course module id '{$quizobj->get_cmid()}'. " . "The grade item this slot contributes to was changed from '' to '$gradeitem->id'.", $event->get_description()); // Check nothing logged if the value is not changed. $sink = $this->redirectEvents(); $stucture->update_slot_grade_item($slot, $gradeitem->id); $events = $sink->get_events(); $this->assertCount(0, $events); } /** * Test the slot moved event. * * There is no external API for moving slot, so the unit test will simply * create and trigger the event and ensure the event data is returned as expected. */ public function test_slot_moved(): void { $quizobj = $this->prepare_quiz(); $params = [ 'objectid' => 1, 'context' => context_module::instance($quizobj->get_cmid()), 'other' => [ 'quizid' => $quizobj->get_quizid(), 'previousslotnumber' => 1, 'afterslotnumber' => 2, 'page' => 1 ] ]; $event = \mod_quiz\event\slot_moved::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('\mod_quiz\event\slot_moved', $event); $this->assertEquals(context_module::instance($quizobj->get_cmid()), $event->get_context()); $this->assertEventContextNotUsed($event); } /** * Test the slot require previous updated event. * * There is no external API for updating slot require previous option, so the unit test will simply * create and trigger the event and ensure the event data is returned as expected. */ public function test_slot_requireprevious_updated(): void { $quizobj = $this->prepare_quiz(); $params = [ 'objectid' => 1, 'context' => context_module::instance($quizobj->get_cmid()), 'other' => [ 'quizid' => $quizobj->get_quizid(), 'requireprevious' => true ] ]; $event = \mod_quiz\event\slot_requireprevious_updated::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('\mod_quiz\event\slot_requireprevious_updated', $event); $this->assertEquals(context_module::instance($quizobj->get_cmid()), $event->get_context()); $this->assertEventContextNotUsed($event); } } PK9B\|-TT&tests/task/precreate_attempts_test.phpnu[. namespace mod_quiz\task; use mod_quiz\quiz_attempt; use mod_quiz\quiz_settings; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->dirroot . '/mod/quiz/tests/quiz_question_helper_test_trait.php'); /** * Unit tests for precreate_attempts * * @package mod_quiz * @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 \mod_quiz\task\precreate_attempts */ final class precreate_attempts_test extends \advanced_testcase { use \quiz_question_helper_test_trait; /** * Generate the various possible combinations precreation settings and the corresponding task output. * * @return array */ public static function precreate_settings_provider(): array { return [ [ 'period' => 0, 'output' => 'Pre-creation of quiz attempts is disabled. Nothing to do.', ], [ 'period' => 1, 'output' => 'Found 0 quizzes to create attempts for.', ], ]; } /** * The scheduled task only looks for quizzes to generate if pre-creation is configured. * * Only look for quizzes to generate attempts for if precreateperiod is not 0, and precreateattempts is 1 or is unlocked. * * @param int $period * @param string $output * @dataProvider precreate_settings_provider */ public function test_execute_disabled(int $period, string $output): void { $this->resetAfterTest(); set_config('precreateperiod', $period, 'quiz'); $task = new precreate_attempts(); ob_start(); $task->execute(); $log = ob_get_clean(); $this->assertMatchesRegularExpression("/{$output}/", $log); } /** * Test precreate attempts task. * * Generate quizzes with a variety of timeopen and precreateperiod settings, and ensure those that match the criteria * for attempt pre-generation are picked up by the scheduled task. */ public function test_execute(): void { $this->resetAfterTest(); // Generate a course. $course = $this->getDataGenerator()->create_course(); // Generate 3 users. $student1 = $this->getDataGenerator()->create_user(); $student2 = $this->getDataGenerator()->create_user(); $teacher = $this->getDataGenerator()->create_user(); // Enrol users on the course with the appropriate roles. $this->getDataGenerator()->enrol_user($student1->id, $course->id, 'student'); $this->getDataGenerator()->enrol_user($student2->id, $course->id, 'student'); $this->getDataGenerator()->enrol_user($teacher->id, $course->id, 'editingteacher'); set_config('precreateperiod', 12 * HOURSECS, 'quiz'); set_config('precreateattempts', 1, 'quiz'); $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz'); // Generate a quiz with timeopen 1 day in the future. $quizinfuture = $quizgenerator->create_instance([ 'course' => $course->id, 'timeopen' => time() + 86400, 'questionsperpage' => 0, 'grade' => 100.0, 'sumgrades' => 2, ]); // Generate a quiz with timeopen 0. $quiznotimeopen = $quizgenerator->create_instance([ 'course' => $course->id, 'precreateperiod' => 43200, 'questionsperpage' => 0, 'grade' => 100.0, 'sumgrades' => 2, ]); // Generate a quiz with timeopen in the past. $quizinpast = $quizgenerator->create_instance([ 'course' => $course->id, 'timeopen' => time() - 86400, 'questionsperpage' => 0, 'grade' => 100.0, 'sumgrades' => 2, ]); // Generate a quiz with timeopen 11 hours in the future. $quizwithattempts = $quizgenerator->create_instance([ 'course' => $course->id, 'timeopen' => time() + 39600, 'questionsperpage' => 0, 'grade' => 100.0, 'sumgrades' => 2, ]); // Generate second quiz with timeopen 11 hours in the future. $quizinprecreateperiod = $quizgenerator->create_instance([ 'course' => $course->id, 'timeopen' => time() + 39600, 'questionsperpage' => 0, 'grade' => 100.0, 'sumgrades' => 2, ]); // Generate second quiz with timeopen 11 hours in the future, // but do not give it any questions. $quizwithoutquestions = $quizgenerator->create_instance([ 'course' => $course->id, 'timeopen' => time() + 39600, 'questionsperpage' => 0, 'grade' => 100.0, 'sumgrades' => 2, ]); // Generate a quiz with teacher's preview and timeopen 11 hours in the future. $quizwithpreviewonly = $quizgenerator->create_instance([ 'course' => $course->id, 'timeopen' => time() + 39600, 'questionsperpage' => 0, 'grade' => 100.0, 'sumgrades' => 2, ]); // Generate a quiz with student attempt, teacher's preview and timeopen 11 hours in the future. $quizwithpreviewandattempt = $quizgenerator->create_instance([ 'course' => $course->id, 'timeopen' => time() + 39600, 'questionsperpage' => 0, 'grade' => 100.0, 'sumgrades' => 2, ]); // Add questions to the quizzes. $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); $this->add_two_regular_questions($questiongenerator, $quizinfuture); $this->add_two_regular_questions($questiongenerator, $quiznotimeopen); $this->add_two_regular_questions($questiongenerator, $quizinpast); $this->add_two_regular_questions($questiongenerator, $quizwithattempts); $this->add_two_regular_questions($questiongenerator, $quizinprecreateperiod); $this->add_two_regular_questions($questiongenerator, $quizwithpreviewonly); $this->add_two_regular_questions($questiongenerator, $quizwithpreviewandattempt); // Create attempts for one student on quiz 5. $this->add_quiz_attempt($quizwithattempts->id, $student1->id, false); // Create attempts for a teacher. $this->add_quiz_attempt($quizwithpreviewonly->id, $teacher->id, true); // Create attempts for a teacher in another quiz. $this->add_quiz_attempt($quizwithpreviewandattempt->id, $teacher->id, true); // Create attempts for student2. $this->add_quiz_attempt($quizwithpreviewandattempt->id, $student2->id, false); $this->assertEmpty( quiz_get_user_attempts( [ $quizinfuture->id, $quiznotimeopen->id, $quizinpast->id, $quizwithoutquestions->id, ], $student1->id, 'all' ) ); $this->assertEmpty( quiz_get_user_attempts( [ $quizinfuture->id, $quiznotimeopen->id, $quizinpast->id, $quizwithattempts->id, $quizwithoutquestions->id, ], $student2->id, 'all', ), ); $this->assertEmpty( quiz_get_user_attempts( [ $quizinfuture->id, $quiznotimeopen->id, $quizinpast->id, $quizwithattempts->id, $quizinprecreateperiod->id, $quizwithoutquestions->id, ], $teacher->id, 'all', ), ); $student1existingattempts = quiz_get_user_attempts($quizwithattempts->id, $student1->id, 'all'); $this->assertCount(1, $student1existingattempts); $this->assertEquals(reset($student1existingattempts)->state, quiz_attempt::IN_PROGRESS); $student1precreatedattempts = quiz_get_user_attempts($quizinprecreateperiod->id, $student1->id, 'all'); $this->assertEmpty($student1precreatedattempts); $student2precreatedattempts = quiz_get_user_attempts($quizinprecreateperiod->id, $student2->id, 'all'); $this->assertEmpty($student2precreatedattempts); $task = new precreate_attempts(); ob_start(); $task->execute(); $log = ob_get_clean(); $this->assertMatchesRegularExpression('/Found 2 quizzes to create attempts for/', $log); $this->assertDoesNotMatchRegularExpression("/Creating attempts for {$quizinfuture->name}/", $log); $this->assertDoesNotMatchRegularExpression("/Creating attempts for {$quiznotimeopen->name}/", $log); $this->assertDoesNotMatchRegularExpression("/Creating attempts for {$quizinpast->name}/", $log); $this->assertDoesNotMatchRegularExpression("/Creating attempts for {$quizwithattempts->name}/", $log); $this->assertMatchesRegularExpression("/Creating attempts for {$quizinprecreateperiod->name}/", $log); $this->assertMatchesRegularExpression("/Created 2 attempts for {$quizinprecreateperiod->name}/", $log); $this->assertDoesNotMatchRegularExpression("/Creating attempts for {$quizwithoutquestions->name}/", $log); $this->assertMatchesRegularExpression("/Created 2 attempts for {$quizwithpreviewonly->name}/", $log); $this->assertDoesNotMatchRegularExpression("/Creating attempts for {$quizwithpreviewandattempt->name}/", $log); $this->assertMatchesRegularExpression('/Created attempts for 2 quizzes./', $log); // Students should have no attempts on quizzes that didn't meet criteria for pre-creation. $this->assertEmpty( quiz_get_user_attempts( [ $quizinfuture->id, $quiznotimeopen->id, $quizinpast->id, $quizwithoutquestions->id, ], $student1->id, 'all' ) ); $this->assertEmpty( quiz_get_user_attempts( [ $quizinfuture->id, $quiznotimeopen->id, $quizinpast->id, $quizwithattempts->id, $quizwithoutquestions->id, ], $student2->id, 'all', ), ); // Teacher should not have any attempts on any quizzes. $this->assertEmpty( quiz_get_user_attempts( [ $quizinfuture->id, $quiznotimeopen->id, $quizinpast->id, $quizwithattempts->id, $quizinprecreateperiod->id, $quizwithoutquestions->id, ], $teacher->id, 'all', ), ); // Students existing attempts should remain. $student1existingattempts = quiz_get_user_attempts($quizwithattempts->id, $student1->id, 'all'); $this->assertCount(1, $student1existingattempts); $this->assertEquals(reset($student1existingattempts)->state, quiz_attempt::IN_PROGRESS); // They should have NOT_STARTED attempts on quizzes that meet the criteria for pre-creation. $student1precreatedattempts = quiz_get_user_attempts($quizinprecreateperiod->id, $student1->id, 'all'); $this->assertCount(1, $student1precreatedattempts); $this->assertEquals(reset($student1precreatedattempts)->state, quiz_attempt::NOT_STARTED); $student2precreatedattempts = quiz_get_user_attempts($quizinprecreateperiod->id, $student2->id, 'all'); $this->assertCount(1, $student2precreatedattempts); $this->assertEquals(reset($student1precreatedattempts)->state, quiz_attempt::NOT_STARTED); } /** * Processing should stop at the end of a quiz once maxruntime has been reached. * * @return void */ public function test_execute_maxruntime(): void { $this->resetAfterTest(); // Generate a course. $course = $this->getDataGenerator()->create_course(); // Generate 3 users. $student1 = $this->getDataGenerator()->create_user(); // Enrol users on the course with the appropriate roles. $this->getDataGenerator()->enrol_user($student1->id, $course->id, 'student'); set_config('precreateperiod', 12 * HOURSECS, 'quiz'); set_config('precreateattempts', 1, 'quiz'); $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz'); // Generate 3 quizzes within the pre-creation window. $timenow = time(); $quiz1 = $quizgenerator->create_instance([ 'course' => $course->id, 'timeopen' => $timenow + 39600, 'questionsperpage' => 0, 'grade' => 100.0, 'sumgrades' => 2, ]); // This quiz opens first, so should be processed first. $quiz2 = $quizgenerator->create_instance([ 'course' => $course->id, 'timeopen' => $timenow + 39599, 'questionsperpage' => 0, 'grade' => 100.0, 'sumgrades' => 2, ]); $quiz3 = $quizgenerator->create_instance([ 'course' => $course->id, 'timeopen' => $timenow + 39600, 'questionsperpage' => 0, 'grade' => 100.0, 'sumgrades' => 2, ]); // Add questions to the quizzes. $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); $this->add_two_regular_questions($questiongenerator, $quiz1); $this->add_two_regular_questions($questiongenerator, $quiz2); $this->add_two_regular_questions($questiongenerator, $quiz3); // Run the task with a maxruntime of 0, so that it should stop after processing the first quiz. $task = new precreate_attempts(0); ob_start(); $task->execute(); $log = ob_get_clean(); // Verify that the task stopped after the quiz opening soonest. $this->assertMatchesRegularExpression('/Found 3 quizzes to create attempts for/', $log); $this->assertMatchesRegularExpression("/Creating attempts for {$quiz2->name}/", $log); $this->assertDoesNotMatchRegularExpression("/Creating attempts for {$quiz1->name}/", $log); $this->assertDoesNotMatchRegularExpression("/Creating attempts for {$quiz3->name}/", $log); $this->assertMatchesRegularExpression("/Created 1 attempts for {$quiz2->name}/", $log); $this->assertMatchesRegularExpression('/Time limit reached./', $log); $this->assertMatchesRegularExpression('/Created attempts for 1 quizzes./', $log); // Run the task again with the default maxruntime. ob_start(); $task = new precreate_attempts(); $task->execute(); $log = ob_get_clean(); // Verify that it picks up the remaining quiz for processing. $this->assertMatchesRegularExpression('/Found 2 quizzes to create attempts for/', $log); $this->assertMatchesRegularExpression("/Creating attempts for {$quiz1->name}/", $log); $this->assertMatchesRegularExpression("/Creating attempts for {$quiz3->name}/", $log); $this->assertDoesNotMatchRegularExpression("/Creating attempts for {$quiz2->name}/", $log); $this->assertMatchesRegularExpression("/Created 1 attempts for {$quiz1->name}/", $log); $this->assertMatchesRegularExpression("/Created 1 attempts for {$quiz3->name}/", $log); $this->assertDoesNotMatchRegularExpression('/Time limit reached./', $log); $this->assertMatchesRegularExpression('/Created attempts for 2 quizzes./', $log); } /** * Pre-creation is opt in based on quiz setting. * * @return void */ public function test_execute_optin(): void { $this->resetAfterTest(); // Generate a course. $course = $this->getDataGenerator()->create_course(); // Generate 3 users. $student1 = $this->getDataGenerator()->create_user(); $student2 = $this->getDataGenerator()->create_user(); $teacher = $this->getDataGenerator()->create_user(); // Enrol users on the course with the appropriate roles. $this->getDataGenerator()->enrol_user($student1->id, $course->id, 'student'); $this->getDataGenerator()->enrol_user($student2->id, $course->id, 'student'); $this->getDataGenerator()->enrol_user($teacher->id, $course->id, 'editingteacher'); // Precreation is disabled by default. set_config('precreateperiod', 12 * HOURSECS, 'quiz'); set_config('precreateattempts', 0, 'quiz'); $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz'); // Generate a quiz with timeopen 11 hours in the future, and precreateattempts set to 1. $quizprecreate = $quizgenerator->create_instance([ 'course' => $course->id, 'timeopen' => time() + 39600, 'questionsperpage' => 0, 'grade' => 100.0, 'sumgrades' => 2, 'precreateattempts' => 1, ]); // Generate a quiz with timeopen 11 hours in the future, and precreateattempts set to 0. $quiznoprecreate = $quizgenerator->create_instance([ 'course' => $course->id, 'timeopen' => time() + 39600, 'questionsperpage' => 0, 'grade' => 100.0, 'sumgrades' => 2, 'precreateattempts' => 0, ]); // Generate a quiz with timeopen 11 hours in the future, and precreateattempts set to null. $quizprecreatenull = $quizgenerator->create_instance([ 'course' => $course->id, 'timeopen' => time() + 39600, 'questionsperpage' => 0, 'grade' => 100.0, 'sumgrades' => 2, ]); $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); $this->add_two_regular_questions($questiongenerator, $quizprecreate); $this->add_two_regular_questions($questiongenerator, $quiznoprecreate); $this->add_two_regular_questions($questiongenerator, $quizprecreatenull); // Run the task. ob_start(); $task = new precreate_attempts(); $task->execute(); $log = ob_get_clean(); // Attempts were now created for the opted-in quiz. $this->assertMatchesRegularExpression('/Found 1 quizzes to create attempts for/', $log); $this->assertMatchesRegularExpression("/Creating attempts for {$quizprecreate->name}/", $log); $this->assertDoesNotMatchRegularExpression("/Creating attempts for {$quiznoprecreate->name}/", $log); $this->assertDoesNotMatchRegularExpression("/Creating attempts for {$quizprecreatenull->name}/", $log); $this->assertMatchesRegularExpression('/Created attempts for 1 quizzes./', $log); // Now enabled by default. set_config('precreateattempts', 1, 'quiz'); // Run the task again. ob_start(); $task = new precreate_attempts(); $task->execute(); $log = ob_get_clean(); // The quiz with null now has attempts generated. $this->assertMatchesRegularExpression('/Found 1 quizzes to create attempts for/', $log); $this->assertDoesNotMatchRegularExpression("/Creating attempts for {$quizprecreate->name}/", $log); $this->assertDoesNotMatchRegularExpression("/Creating attempts for {$quiznoprecreate->name}/", $log); $this->assertMatchesRegularExpression("/Creating attempts for {$quizprecreatenull->name}/", $log); $this->assertMatchesRegularExpression('/Created attempts for 1 quizzes./', $log); } /** * Helper to add a quiz attempt for a user. * * @param int $quizid * @param int $userid * @param bool $ispreview * @return void */ protected function add_quiz_attempt($quizid, $userid, bool $ispreview) { $quiz5settings = quiz_settings::create($quizid); $quba = \question_engine::make_questions_usage_by_activity('mod_quiz', $quiz5settings->get_context()); $quba->set_preferred_behaviour($quiz5settings->get_quiz()->preferredbehaviour); $attempt = quiz_create_attempt($quiz5settings, 1, false, time(), $ispreview, $userid); quiz_start_new_attempt($quiz5settings, $quba, $attempt, 1, time()); quiz_attempt_save_started($quiz5settings, $quba, $attempt); } } PK9B\7%'tests/question/display_options_test.phpnu[. namespace mod_quiz\question; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->dirroot . '/mod/quiz/locallib.php'); /** * Unit tests for {@see display_options}. * * @package mod_quiz * @category test * @copyright 2010 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @covers \mod_quiz\question\display_options */ final class display_options_test extends \basic_testcase { public function test_num_attempts_access_rule(): void { $quiz = new \stdClass(); $quiz->decimalpoints = 2; $quiz->questiondecimalpoints = -1; $quiz->reviewattempt = 0x11110; $quiz->reviewcorrectness = 0x10000; $quiz->reviewmaxmarks = 0x10000; // Max marks is set. $quiz->reviewmarks = 0x00000; // Marks is not set. $quiz->reviewspecificfeedback = 0x10000; $quiz->reviewgeneralfeedback = 0x01000; $quiz->reviewrightanswer = 0x00100; $quiz->reviewoverallfeedback = 0x00010; $options = display_options::make_from_quiz($quiz, display_options::DURING); $this->assertEquals(true, $options->attempt); $this->assertEquals(display_options::VISIBLE, $options->correctness); $this->assertEquals(display_options::MAX_ONLY, $options->marks); $this->assertEquals(display_options::VISIBLE, $options->feedback); // The next two should be controlled by the same settings as ->feedback. $this->assertEquals(display_options::VISIBLE, $options->numpartscorrect); $this->assertEquals(display_options::VISIBLE, $options->manualcomment); $this->assertEquals(2, $options->markdp); $quiz->questiondecimalpoints = 5; $quiz->reviewmaxmarks = 0x11000; // Max marks is set. $quiz->reviewmarks = 0x11000; // Marks is also set. $options = display_options::make_from_quiz($quiz, display_options::IMMEDIATELY_AFTER); $this->assertEquals(display_options::MARK_AND_MAX, $options->marks); $this->assertEquals(display_options::VISIBLE, $options->generalfeedback); $this->assertEquals(display_options::HIDDEN, $options->feedback); // The next two should be controlled by the same settings as ->feedback. $this->assertEquals(display_options::HIDDEN, $options->numpartscorrect); $this->assertEquals(display_options::HIDDEN, $options->manualcomment); $this->assertEquals(5, $options->markdp); $quiz->reviewmaxmarks = 0x00000; // Max marks is NOT set. $quiz->reviewmarks = 0x00000; // Marks is also NOT set. $options = display_options::make_from_quiz($quiz, display_options::LATER_WHILE_OPEN); $this->assertEquals(display_options::HIDDEN, $options->marks); $this->assertEquals(display_options::VISIBLE, $options->rightanswer); $this->assertEquals(display_options::HIDDEN, $options->generalfeedback); $options = display_options::make_from_quiz($quiz, display_options::AFTER_CLOSE); $this->assertEquals(display_options::VISIBLE, $options->overallfeedback); $this->assertEquals(display_options::HIDDEN, $options->rightanswer); } } PK9B\'1 00*tests/local_structure_slot_random_test.phpnu[. namespace mod_quiz; defined('MOODLE_INTERNAL') || die(); use mod_quiz\question\bank\qbank_helper; global $CFG; require_once($CFG->dirroot . '/question/engine/tests/helpers.php'); require_once($CFG->dirroot . '/mod/quiz/tests/quiz_question_helper_test_trait.php'); /** * Class mod_quiz_local_structure_slot_random_test * Class for tests related to the {@link \mod_quiz\local\structure\slot_random} class. * * @package mod_quiz * @category test * @copyright 2018 Shamim Rezaie * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @covers \mod_quiz\local\structure\slot_random */ final class local_structure_slot_random_test extends \advanced_testcase { use \quiz_question_helper_test_trait; /** * Constructor test. */ public function test_constructor(): void { global $SITE; $this->resetAfterTest(); $this->setAdminUser(); // Create a quiz. $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz'); $quiz = $quizgenerator->create_instance(['course' => $SITE->id, 'questionsperpage' => 3, 'grade' => 100.0]); // Create a question category in the system context. $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); $category = $questiongenerator->create_question_category(); // Create a random question without adding it to a quiz. // We don't want to use quiz_add_random_questions because that itself, instantiates an object from the slot_random class. $form = new \stdClass(); $form->category = $category->id . ',' . $category->contextid; $form->includesubcategories = true; $form->fromtags = []; $form->defaultmark = 1; $form->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_HIDDEN; $form->stamp = make_unique_id_code(); // Set the filter conditions. $filtercondition = new \stdClass(); $filtercondition->filters = \question_filter_test_helper::create_filters([$category->id], true); // Slot data. $randomslotdata = new \stdClass(); $randomslotdata->quizid = $quiz->id; $randomslotdata->maxmark = 1; $randomslotdata->usingcontextid = \context_module::instance($quiz->cmid)->id; $randomslotdata->questionscontextid = $category->contextid; // Insert the random question to the quiz. $randomslot = new \mod_quiz\local\structure\slot_random($randomslotdata); $randomslot->set_filter_condition(json_encode($filtercondition)); $rc = new \ReflectionClass('\mod_quiz\local\structure\slot_random'); $rcp = $rc->getProperty('filtercondition'); $record = json_decode($rcp->getValue($randomslot)); $this->assertEquals($quiz->id, $randomslot->get_quiz()->id); $this->assertEquals($category->id, $record->filters->category->values[0]); $this->assertTrue($record->filters->category->filteroptions->includesubcategories); $rcp = $rc->getProperty('record'); $record = $rcp->getValue($randomslot); $this->assertEquals(1, $record->maxmark); } public function test_get_quiz_quiz(): void { global $SITE, $DB; $this->resetAfterTest(); $this->setAdminUser(); // Create a quiz. $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz'); $quiz = $quizgenerator->create_instance(['course' => $SITE->id, 'questionsperpage' => 3, 'grade' => 100.0]); // Create a question category in the system context. $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); $category = $questiongenerator->create_question_category(); $this->add_random_questions($quiz->id, 0, $category->id, 1); // Set the filter conditions. $filtercondition = new \stdClass(); $filtercondition->filters = \question_filter_test_helper::create_filters([$category->id], 1); // Slot data. $randomslotdata = new \stdClass(); $randomslotdata->quizid = $quiz->id; $randomslotdata->maxmark = 1; $randomslotdata->usingcontextid = \context_module::instance($quiz->cmid)->id; $randomslotdata->questionscontextid = $category->contextid; $randomslot = new \mod_quiz\local\structure\slot_random($randomslotdata); $randomslot->set_filter_condition(json_encode($filtercondition)); // The create_instance had injected an additional cmid propery to the quiz. Let's remove that. unset($quiz->cmid); $this->assertEquals($quiz, $randomslot->get_quiz()); } public function test_set_quiz(): void { global $SITE, $DB; $this->resetAfterTest(); $this->setAdminUser(); // Create a quiz. $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz'); $quiz = $quizgenerator->create_instance(['course' => $SITE->id, 'questionsperpage' => 3, 'grade' => 100.0]); // Create a question category in the system context. $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); $category = $questiongenerator->create_question_category(); $this->add_random_questions($quiz->id, 0, $category->id, 1); // Set the filter conditions. $filtercondition = new \stdClass(); $filtercondition->filters = \question_filter_test_helper::create_filters([$category->id], 1); // Slot data. $randomslotdata = new \stdClass(); $randomslotdata->quizid = $quiz->id; $randomslotdata->maxmark = 1; $randomslotdata->usingcontextid = \context_module::instance($quiz->cmid)->id; $randomslotdata->questionscontextid = $category->contextid; $randomslot = new \mod_quiz\local\structure\slot_random($randomslotdata); $randomslot->set_filter_condition(json_encode($filtercondition)); // The create_instance had injected an additional cmid propery to the quiz. Let's remove that. unset($quiz->cmid); $randomslot->set_quiz($quiz); $rc = new \ReflectionClass('\mod_quiz\local\structure\slot_random'); $rcp = $rc->getProperty('quiz'); $quizpropery = $rcp->getValue($randomslot); $this->assertEquals($quiz, $quizpropery); } private function setup_for_test_tags($tagnames) { global $SITE, $DB; // Create a quiz. $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz'); $quiz = $quizgenerator->create_instance(['course' => $SITE->id, 'questionsperpage' => 3, 'grade' => 100.0]); // Create a question category in the system context. $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); $category = $questiongenerator->create_question_category(); $this->add_random_questions($quiz->id, 0, $category->id, 1); // Slot data. $randomslotdata = new \stdClass(); $randomslotdata->quizid = $quiz->id; $randomslotdata->maxmark = 1; $randomslotdata->usingcontextid = \context_module::instance($quiz->cmid)->id; $randomslotdata->questionscontextid = $category->contextid; $randomslot = new \mod_quiz\local\structure\slot_random($randomslotdata); // Create tags. foreach ($tagnames as $tagname) { $tagrecord = [ 'isstandard' => 1, 'flag' => 0, 'rawname' => $tagname, 'description' => $tagname . ' desc' ]; $tags[$tagname] = $this->getDataGenerator()->create_tag($tagrecord); } return [$randomslot, $tags]; } public function test_set_tags_filter(): void { $this->resetAfterTest(); $this->setAdminUser(); list($randomslot, $tags) = $this->setup_for_test_tags(['foo', 'bar']); $qtagids = [$tags['foo']->id, $tags['bar']->id]; $filtercondition = new \stdClass(); $filtercondition->filters = \question_filter_test_helper::create_filters([], 0, $qtagids); $randomslot->set_filter_condition(json_encode($filtercondition)); $rc = new \ReflectionClass('\mod_quiz\local\structure\slot_random'); $rcp = $rc->getProperty('filtercondition'); $tagspropery = $rcp->getValue($randomslot); $this->assertEquals([$tags['foo']->id, $tags['bar']->id], (array)json_decode($tagspropery)->filters->qtagids->values); } public function test_insert(): void { global $SITE; $this->resetAfterTest(); $this->setAdminUser(); // Create a quiz. $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz'); $quiz = $quizgenerator->create_instance(['course' => $SITE->id, 'questionsperpage' => 3, 'grade' => 100.0]); $quizcontext = \context_module::instance($quiz->cmid); // Create a question category in the system context. $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); $category = $questiongenerator->create_question_category(); // Create a random question without adding it to a quiz. $form = new \stdClass(); $form->category = $category->id . ',' . $category->contextid; $form->includesubcategories = true; $form->fromtags = []; $form->defaultmark = 1; $form->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_HIDDEN; $form->stamp = make_unique_id_code(); // Prepare 2 tags. $tagrecord = [ 'isstandard' => 1, 'flag' => 0, 'rawname' => 'foo', 'description' => 'foo desc' ]; $footag = $this->getDataGenerator()->create_tag($tagrecord); $tagrecord = [ 'isstandard' => 1, 'flag' => 0, 'rawname' => 'bar', 'description' => 'bar desc' ]; $bartag = $this->getDataGenerator()->create_tag($tagrecord); // Set the filter conditions. $filtercondition = new \stdClass(); $filtercondition->filter = \question_filter_test_helper::create_filters([$category->id], true, [$footag->id, $bartag->id]); // Slot data. $randomslotdata = new \stdClass(); $randomslotdata->quizid = $quiz->id; $randomslotdata->maxmark = 1; $randomslotdata->usingcontextid = $quizcontext->id; $randomslotdata->questionscontextid = $category->contextid; // Insert the random question to the quiz. $randomslot = new \mod_quiz\local\structure\slot_random($randomslotdata); $randomslot->set_filter_condition(json_encode($filtercondition)); $randomslot->insert(1); // Put the question on the first page of the quiz. $slots = qbank_helper::get_question_structure($quiz->id, $quizcontext); $quizslot = reset($slots); $filter = $quizslot->filtercondition['filter']; $this->assertEquals($category->id, $filter['category']['values'][0]); $this->assertTrue($filter['category']['filteroptions']['includesubcategories']); $this->assertEquals(1, $quizslot->maxmark); $this->assertCount(2, $filter['qtagids']['values']); $this->assertEqualsCanonicalizing( [ ['tagid' => $footag->id], ['tagid' => $bartag->id] ], array_map(function($tagid) { return ['tagid' => $tagid]; }, $filter['qtagids']['values'])); } } PK9B\stests/attempts_test.phpnu[. namespace mod_quiz; use core_question_generator; use mod_quiz\task\update_overdue_attempts; use mod_quiz_generator; use moodle_recordset; use question_engine; use mod_quiz\quiz_settings; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->dirroot.'/group/lib.php'); /** * Unit tests for quiz attempt overdue handling * * @package mod_quiz * @category test * @copyright 2012 Matt Petro * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ #[\PHPUnit\Framework\Attributes\CoversClass(update_overdue_attempts::class)] final class attempts_test extends \advanced_testcase { /** * Test the functions quiz_update_open_attempts(), get_list_of_overdue_attempts() and * update_overdue_attempts(). */ public function test_bulk_update_functions(): void { global $DB; $this->resetAfterTest(); $this->setAdminUser(); // Setup course, user and groups $course = $this->getDataGenerator()->create_course(); $user1 = $this->getDataGenerator()->create_user(); $studentrole = $DB->get_record('role', ['shortname' => 'student']); $this->assertNotEmpty($studentrole); $this->assertTrue(enrol_try_internal_enrol($course->id, $user1->id, $studentrole->id)); $group1 = $this->getDataGenerator()->create_group(['courseid' => $course->id]); $group2 = $this->getDataGenerator()->create_group(['courseid' => $course->id]); $group3 = $this->getDataGenerator()->create_group(['courseid' => $course->id]); $this->assertTrue(groups_add_member($group1, $user1)); $this->assertTrue(groups_add_member($group2, $user1)); $usertimes = []; /** @var mod_quiz_generator $quizgenerator */ $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz'); // Basic quiz settings $quiz = $quizgenerator->create_instance(['course' => $course->id, 'timeclose' => 1200, 'timelimit' => 600]); $attemptid = $DB->insert_record('quiz_attempts', ['quiz' => $quiz->id, 'userid' => $user1->id, 'state' => 'inprogress', 'timestart' => 100, 'timecheckstate' => 0, 'layout' => '', 'uniqueid' => $this->usage_id($quiz)]); $usertimes[$attemptid] = ['timeclose' => 1200, 'timelimit' => 600, 'message' => 'Test1A', 'time1000state' => 'finished']; $quiz = $quizgenerator->create_instance(['course' => $course->id, 'timeclose' => 1200, 'timelimit' => 1800]); $attemptid = $DB->insert_record('quiz_attempts', ['quiz' => $quiz->id, 'userid' => $user1->id, 'state' => 'inprogress', 'timestart' => 100, 'timecheckstate' => 0, 'layout' => '', 'uniqueid' => $this->usage_id($quiz)]); $usertimes[$attemptid] = ['timeclose' => 1200, 'timelimit' => 1800, 'message' => 'Test1B', 'time1000state' => 'inprogress']; $quiz = $quizgenerator->create_instance(['course' => $course->id, 'timeclose' => 1200, 'timelimit' => 0]); $attemptid = $DB->insert_record('quiz_attempts', ['quiz' => $quiz->id, 'userid' => $user1->id, 'state' => 'inprogress', 'timestart' => 100, 'timecheckstate' => 0, 'layout' => '', 'uniqueid' => $this->usage_id($quiz)]); $usertimes[$attemptid] = ['timeclose' => 1200, 'timelimit' => 0, 'message' => 'Test1C', 'time1000state' => 'inprogress']; $quiz = $quizgenerator->create_instance(['course' => $course->id, 'timeclose' => 0, 'timelimit' => 600]); $attemptid = $DB->insert_record('quiz_attempts', ['quiz' => $quiz->id, 'userid' => $user1->id, 'state' => 'inprogress', 'timestart' => 100, 'timecheckstate' => 0, 'layout' => '', 'uniqueid' => $this->usage_id($quiz)]); $usertimes[$attemptid] = ['timeclose' => 0, 'timelimit' => 600, 'message' => 'Test1D', 'time1000state' => 'finished']; $quiz = $quizgenerator->create_instance(['course' => $course->id, 'timeclose' => 0, 'timelimit' => 0]); $attemptid = $DB->insert_record('quiz_attempts', ['quiz' => $quiz->id, 'userid' => $user1->id, 'state' => 'inprogress', 'timestart' => 100, 'timecheckstate' => 0, 'layout' => '', 'uniqueid' => $this->usage_id($quiz)]); $usertimes[$attemptid] = ['timeclose' => 0, 'timelimit' => 0, 'message' => 'Test1E', 'time1000state' => 'inprogress']; // Group overrides $quiz = $quizgenerator->create_instance(['course' => $course->id, 'timeclose' => 1200, 'timelimit' => 0]); $attemptid = $DB->insert_record('quiz_attempts', ['quiz' => $quiz->id, 'userid' => $user1->id, 'state' => 'inprogress', 'timestart' => 100, 'timecheckstate' => 0, 'layout' => '', 'uniqueid' => $this->usage_id($quiz)]); $DB->insert_record('quiz_overrides', ['quiz' => $quiz->id, 'groupid' => $group1->id, 'timeclose' => 1300, 'timelimit' => null]); $usertimes[$attemptid] = ['timeclose' => 1300, 'timelimit' => 0, 'message' => 'Test2A', 'time1000state' => 'inprogress']; $quiz = $quizgenerator->create_instance(['course' => $course->id, 'timeclose' => 1200, 'timelimit' => 0]); $DB->insert_record('quiz_overrides', ['quiz' => $quiz->id, 'groupid' => $group1->id, 'timeclose' => 1100, 'timelimit' => null]); $attemptid = $DB->insert_record('quiz_attempts', ['quiz' => $quiz->id, 'userid' => $user1->id, 'state' => 'inprogress', 'timestart' => 100, 'timecheckstate' => 0, 'layout' => '', 'uniqueid' => $this->usage_id($quiz)]); $usertimes[$attemptid] = ['timeclose' => 1100, 'timelimit' => 0, 'message' => 'Test2B', 'time1000state' => 'inprogress']; $quiz = $quizgenerator->create_instance( ['course' => $course->id, 'timeclose' => 0, 'timelimit' => 600, 'overduehandling' => 'autoabandon']); $DB->insert_record('quiz_overrides', ['quiz' => $quiz->id, 'groupid' => $group1->id, 'timeclose' => null, 'timelimit' => 700]); $attemptid = $DB->insert_record('quiz_attempts', ['quiz' => $quiz->id, 'userid' => $user1->id, 'state' => 'inprogress', 'timestart' => 100, 'timecheckstate' => 0, 'layout' => '', 'uniqueid' => $this->usage_id($quiz)]); $usertimes[$attemptid] = ['timeclose' => 0, 'timelimit' => 700, 'message' => 'Test2C', 'time1000state' => 'abandoned']; $quiz = $quizgenerator->create_instance(['course' => $course->id, 'timeclose' => 0, 'timelimit' => 600]); $DB->insert_record('quiz_overrides', ['quiz' => $quiz->id, 'groupid' => $group1->id, 'timeclose' => null, 'timelimit' => 500]); $attemptid = $DB->insert_record('quiz_attempts', ['quiz' => $quiz->id, 'userid' => $user1->id, 'state' => 'inprogress', 'timestart' => 100, 'timecheckstate' => 0, 'layout' => '', 'uniqueid' => $this->usage_id($quiz)]); $usertimes[$attemptid] = ['timeclose' => 0, 'timelimit' => 500, 'message' => 'Test2D', 'time1000state' => 'finished']; $quiz = $quizgenerator->create_instance(['course' => $course->id, 'timeclose' => 0, 'timelimit' => 600]); $DB->insert_record('quiz_overrides', ['quiz' => $quiz->id, 'groupid' => $group1->id, 'timeclose' => null, 'timelimit' => 0]); $attemptid = $DB->insert_record('quiz_attempts', ['quiz' => $quiz->id, 'userid' => $user1->id, 'state' => 'inprogress', 'timestart' => 100, 'timecheckstate' => 0, 'layout' => '', 'uniqueid' => $this->usage_id($quiz)]); $usertimes[$attemptid] = ['timeclose' => 0, 'timelimit' => 0, 'message' => 'Test2E', 'time1000state' => 'inprogress']; $quiz = $quizgenerator->create_instance(['course' => $course->id, 'timeclose' => 1200, 'timelimit' => 600]); $DB->insert_record('quiz_overrides', ['quiz' => $quiz->id, 'groupid' => $group1->id, 'timeclose' => 1300, 'timelimit' => 500]); $attemptid = $DB->insert_record('quiz_attempts', ['quiz' => $quiz->id, 'userid' => $user1->id, 'state' => 'inprogress', 'timestart' => 100, 'timecheckstate' => 0, 'layout' => '', 'uniqueid' => $this->usage_id($quiz)]); $usertimes[$attemptid] = ['timeclose' => 1300, 'timelimit' => 500, 'message' => 'Test2F', 'time1000state' => 'finished']; $attemptid = $DB->insert_record('quiz_attempts', ['quiz' => $quiz->id, 'userid' => $user1->id, 'state' => 'inprogress', 'timestart' => 1000, 'timecheckstate' => 0, 'layout' => '', 'uniqueid' => $this->usage_id($quiz), 'attempt' => 1]); $usertimes[$attemptid] = ['timeclose' => 1300, 'timelimit' => 500, 'message' => 'Test2G', 'time1000state' => 'inprogress']; $quiz = $quizgenerator->create_instance(['course' => $course->id, 'timeclose' => 1200, 'timelimit' => 600]); $DB->insert_record('quiz_overrides', ['quiz' => $quiz->id, 'groupid' => $group3->id, 'timeclose' => 1300, 'timelimit' => 500]); // User not in group. $attemptid = $DB->insert_record('quiz_attempts', ['quiz' => $quiz->id, 'userid' => $user1->id, 'state' => 'inprogress', 'timestart' => 100, 'timecheckstate' => 0, 'layout' => '', 'uniqueid' => $this->usage_id($quiz)]); $usertimes[$attemptid] = ['timeclose' => 1200, 'timelimit' => 600, 'message' => 'Test2H', 'time1000state' => 'finished']; $attemptid = $DB->insert_record('quiz_attempts', ['quiz' => $quiz->id, 'userid' => $user1->id, 'state' => 'inprogress', 'timestart' => 1000, 'timecheckstate' => 0, 'layout' => '', 'uniqueid' => $this->usage_id($quiz), 'attempt' => 1]); $usertimes[$attemptid] = ['timeclose' => 1200, 'timelimit' => 600, 'message' => 'Test2I', 'time1000state' => 'inprogress']; // Multiple group overrides $quiz = $quizgenerator->create_instance(['course' => $course->id, 'timeclose' => 1200, 'timelimit' => 600]); $DB->insert_record('quiz_overrides', ['quiz' => $quiz->id, 'groupid' => $group1->id, 'timeclose' => 1300, 'timelimit' => 501]); $DB->insert_record('quiz_overrides', ['quiz' => $quiz->id, 'groupid' => $group2->id, 'timeclose' => 1301, 'timelimit' => 500]); $attemptid = $DB->insert_record('quiz_attempts', ['quiz' => $quiz->id, 'userid' => $user1->id, 'state' => 'inprogress', 'timestart' => 100, 'timecheckstate' => 0, 'layout' => '', 'uniqueid' => $this->usage_id($quiz)]); $usertimes[$attemptid] = ['timeclose' => 1301, 'timelimit' => 501, 'message' => 'Test3A', 'time1000state' => 'finished']; $attemptid = $DB->insert_record('quiz_attempts', ['quiz' => $quiz->id, 'userid' => $user1->id, 'state' => 'inprogress', 'timestart' => 1000, 'timecheckstate' => 0, 'layout' => '', 'uniqueid' => $this->usage_id($quiz), 'attempt' => 1]); $usertimes[$attemptid] = ['timeclose' => 1301, 'timelimit' => 501, 'message' => 'Test3B', 'time1000state' => 'inprogress']; $quiz = $quizgenerator->create_instance(['course' => $course->id, 'timeclose' => 1200, 'timelimit' => 600]); $DB->insert_record('quiz_overrides', ['quiz' => $quiz->id, 'groupid' => $group1->id, 'timeclose' => 1301, 'timelimit' => 500]); $DB->insert_record('quiz_overrides', ['quiz' => $quiz->id, 'groupid' => $group2->id, 'timeclose' => 1300, 'timelimit' => 501]); $attemptid = $DB->insert_record('quiz_attempts', ['quiz' => $quiz->id, 'userid' => $user1->id, 'state' => 'inprogress', 'timestart' => 100, 'timecheckstate' => 0, 'layout' => '', 'uniqueid' => $this->usage_id($quiz)]); $usertimes[$attemptid] = ['timeclose' => 1301, 'timelimit' => 501, 'message' => 'Test3C', 'time1000state' => 'finished']; $attemptid = $DB->insert_record('quiz_attempts', ['quiz' => $quiz->id, 'userid' => $user1->id, 'state' => 'inprogress', 'timestart' => 1000, 'timecheckstate' => 0, 'layout' => '', 'uniqueid' => $this->usage_id($quiz), 'attempt' => 1]); $usertimes[$attemptid] = ['timeclose' => 1301, 'timelimit' => 501, 'message' => 'Test3D', 'time1000state' => 'inprogress']; $quiz = $quizgenerator->create_instance(['course' => $course->id, 'timeclose' => 1200, 'timelimit' => 600, 'overduehandling' => 'autoabandon']); $DB->insert_record('quiz_overrides', ['quiz' => $quiz->id, 'groupid' => $group1->id, 'timeclose' => 1301, 'timelimit' => 500]); $DB->insert_record('quiz_overrides', ['quiz' => $quiz->id, 'groupid' => $group2->id, 'timeclose' => 1300, 'timelimit' => 501]); $DB->insert_record('quiz_overrides', ['quiz' => $quiz->id, 'groupid' => $group3->id, 'timeclose' => 1500, 'timelimit' => 1000]); // User not in group. $attemptid = $DB->insert_record('quiz_attempts', ['quiz' => $quiz->id, 'userid' => $user1->id, 'state' => 'inprogress', 'timestart' => 100, 'timecheckstate' => 0, 'layout' => '', 'uniqueid' => $this->usage_id($quiz)]); $usertimes[$attemptid] = ['timeclose' => 1301, 'timelimit' => 501, 'message' => 'Test3E', 'time1000state' => 'abandoned']; $attemptid = $DB->insert_record('quiz_attempts', ['quiz' => $quiz->id, 'userid' => $user1->id, 'state' => 'inprogress', 'timestart' => 1000, 'timecheckstate' => 0, 'layout' => '', 'uniqueid' => $this->usage_id($quiz), 'attempt' => 1]); $usertimes[$attemptid] = ['timeclose' => 1301, 'timelimit' => 501, 'message' => 'Test3F', 'time1000state' => 'inprogress']; $quiz = $quizgenerator->create_instance(['course' => $course->id, 'timeclose' => 1200, 'timelimit' => 600]); $DB->insert_record('quiz_overrides', ['quiz' => $quiz->id, 'groupid' => $group1->id, 'timeclose' => 1300, 'timelimit' => 500]); $DB->insert_record('quiz_overrides', ['quiz' => $quiz->id, 'groupid' => $group2->id, 'timeclose' => null, 'timelimit' => 501]); $attemptid = $DB->insert_record('quiz_attempts', ['quiz' => $quiz->id, 'userid' => $user1->id, 'state' => 'inprogress', 'timestart' => 100, 'timecheckstate' => 0, 'layout' => '', 'uniqueid' => $this->usage_id($quiz)]); $usertimes[$attemptid] = ['timeclose' => 1300, 'timelimit' => 501, 'message' => 'Test3G', 'time1000state' => 'finished']; $attemptid = $DB->insert_record('quiz_attempts', ['quiz' => $quiz->id, 'userid' => $user1->id, 'state' => 'inprogress', 'timestart' => 1000, 'timecheckstate' => 0, 'layout' => '', 'uniqueid' => $this->usage_id($quiz), 'attempt' => 1]); $usertimes[$attemptid] = ['timeclose' => 1300, 'timelimit' => 501, 'message' => 'Test3H', 'time1000state' => 'inprogress']; $quiz = $quizgenerator->create_instance(['course' => $course->id, 'timeclose' => 1200, 'timelimit' => 600]); $DB->insert_record('quiz_overrides', ['quiz' => $quiz->id, 'groupid' => $group1->id, 'timeclose' => 1300, 'timelimit' => 500]); $DB->insert_record('quiz_overrides', ['quiz' => $quiz->id, 'groupid' => $group2->id, 'timeclose' => 1301, 'timelimit' => null]); $attemptid = $DB->insert_record('quiz_attempts', ['quiz' => $quiz->id, 'userid' => $user1->id, 'state' => 'inprogress', 'timestart' => 100, 'timecheckstate' => 0, 'layout' => '', 'uniqueid' => $this->usage_id($quiz)]); $usertimes[$attemptid] = ['timeclose' => 1301, 'timelimit' => 500, 'message' => 'Test3I', 'time1000state' => 'finished']; $attemptid = $DB->insert_record('quiz_attempts', ['quiz' => $quiz->id, 'userid' => $user1->id, 'state' => 'inprogress', 'timestart' => 1000, 'timecheckstate' => 0, 'layout' => '', 'uniqueid' => $this->usage_id($quiz), 'attempt' => 1]); $usertimes[$attemptid] = ['timeclose' => 1301, 'timelimit' => 500, 'message' => 'Test3J', 'time1000state' => 'inprogress']; $quiz = $quizgenerator->create_instance(['course' => $course->id, 'timeclose' => 1200, 'timelimit' => 600]); $DB->insert_record('quiz_overrides', ['quiz' => $quiz->id, 'groupid' => $group1->id, 'timeclose' => 1300, 'timelimit' => 500]); $DB->insert_record('quiz_overrides', ['quiz' => $quiz->id, 'groupid' => $group2->id, 'timeclose' => 1301, 'timelimit' => 0]); $attemptid = $DB->insert_record('quiz_attempts', ['quiz' => $quiz->id, 'userid' => $user1->id, 'state' => 'inprogress', 'timestart' => 100, 'timecheckstate' => 0, 'layout' => '', 'uniqueid' => $this->usage_id($quiz)]); $usertimes[$attemptid] = ['timeclose' => 1301, 'timelimit' => 0, 'message' => 'Test3K', 'time1000state' => 'inprogress']; $attemptid = $DB->insert_record('quiz_attempts', ['quiz' => $quiz->id, 'userid' => $user1->id, 'state' => 'inprogress', 'timestart' => 1000, 'timecheckstate' => 0, 'layout' => '', 'uniqueid' => $this->usage_id($quiz), 'attempt' => 1]); $usertimes[$attemptid] = ['timeclose' => 1301, 'timelimit' => 0, 'message' => 'Test3L', 'time1000state' => 'inprogress']; $quiz = $quizgenerator->create_instance(['course' => $course->id, 'timeclose' => 1200, 'timelimit' => 600]); $DB->insert_record('quiz_overrides', ['quiz' => $quiz->id, 'groupid' => $group1->id, 'timeclose' => 1300, 'timelimit' => 500]); $DB->insert_record('quiz_overrides', ['quiz' => $quiz->id, 'groupid' => $group2->id, 'timeclose' => 0, 'timelimit' => 501]); $attemptid = $DB->insert_record('quiz_attempts', ['quiz' => $quiz->id, 'userid' => $user1->id, 'state' => 'inprogress', 'timestart' => 100, 'timecheckstate' => 0, 'layout' => '', 'uniqueid' => $this->usage_id($quiz)]); $usertimes[$attemptid] = ['timeclose' => 0, 'timelimit' => 501, 'message' => 'Test3M', 'time1000state' => 'finished']; $attemptid = $DB->insert_record('quiz_attempts', ['quiz' => $quiz->id, 'userid' => $user1->id, 'state' => 'inprogress', 'timestart' => 1000, 'timecheckstate' => 0, 'layout' => '', 'uniqueid' => $this->usage_id($quiz), 'attempt' => 1]); $usertimes[$attemptid] = ['timeclose' => 0, 'timelimit' => 501, 'message' => 'Test3N', 'time1000state' => 'inprogress']; // User overrides $quiz = $quizgenerator->create_instance(['course' => $course->id, 'timeclose' => 1200, 'timelimit' => 600]); $DB->insert_record('quiz_overrides', ['quiz' => $quiz->id, 'groupid' => $group1->id, 'timeclose' => 1300, 'timelimit' => 700]); $DB->insert_record('quiz_overrides', ['quiz' => $quiz->id, 'userid' => $user1->id, 'timeclose' => 1201, 'timelimit' => 601]); $attemptid = $DB->insert_record('quiz_attempts', ['quiz' => $quiz->id, 'userid' => $user1->id, 'state' => 'inprogress', 'timestart' => 100, 'timecheckstate' => 0, 'layout' => '', 'uniqueid' => $this->usage_id($quiz)]); $usertimes[$attemptid] = ['timeclose' => 1201, 'timelimit' => 601, 'message' => 'Test4A', 'time1000state' => 'finished']; $attemptid = $DB->insert_record('quiz_attempts', ['quiz' => $quiz->id, 'userid' => $user1->id, 'state' => 'inprogress', 'timestart' => 1000, 'timecheckstate' => 0, 'layout' => '', 'uniqueid' => $this->usage_id($quiz), 'attempt' => 1]); $usertimes[$attemptid] = ['timeclose' => 1201, 'timelimit' => 601, 'message' => 'Test4B', 'time1000state' => 'inprogress']; $quiz = $quizgenerator->create_instance(['course' => $course->id, 'timeclose' => 1200, 'timelimit' => 600]); $DB->insert_record('quiz_overrides', ['quiz' => $quiz->id, 'groupid' => $group1->id, 'timeclose' => 1300, 'timelimit' => 700]); $DB->insert_record('quiz_overrides', ['quiz' => $quiz->id, 'userid' => $user1->id, 'timeclose' => 0, 'timelimit' => 601]); $attemptid = $DB->insert_record('quiz_attempts', ['quiz' => $quiz->id, 'userid' => $user1->id, 'state' => 'inprogress', 'timestart' => 100, 'timecheckstate' => 0, 'layout' => '', 'uniqueid' => $this->usage_id($quiz)]); $usertimes[$attemptid] = ['timeclose' => 0, 'timelimit' => 601, 'message' => 'Test4C', 'time1000state' => 'finished']; $attemptid = $DB->insert_record('quiz_attempts', ['quiz' => $quiz->id, 'userid' => $user1->id, 'state' => 'inprogress', 'timestart' => 1000, 'timecheckstate' => 0, 'layout' => '', 'uniqueid' => $this->usage_id($quiz), 'attempt' => 1]); $usertimes[$attemptid] = ['timeclose' => 0, 'timelimit' => 601, 'message' => 'Test4D', 'time1000state' => 'inprogress']; $quiz = $quizgenerator->create_instance(['course' => $course->id, 'timeclose' => 1200, 'timelimit' => 600]); $DB->insert_record('quiz_overrides', ['quiz' => $quiz->id, 'groupid' => $group1->id, 'timeclose' => 1300, 'timelimit' => 700]); $DB->insert_record('quiz_overrides', ['quiz' => $quiz->id, 'userid' => $user1->id, 'timeclose' => 1201, 'timelimit' => 0]); $attemptid = $DB->insert_record('quiz_attempts', ['quiz' => $quiz->id, 'userid' => $user1->id, 'state' => 'inprogress', 'timestart' => 100, 'timecheckstate' => 0, 'layout' => '', 'uniqueid' => $this->usage_id($quiz)]); $usertimes[$attemptid] = ['timeclose' => 1201, 'timelimit' => 0, 'message' => 'Test4E', 'time1000state' => 'inprogress']; $attemptid = $DB->insert_record('quiz_attempts', ['quiz' => $quiz->id, 'userid' => $user1->id, 'state' => 'inprogress', 'timestart' => 1000, 'timecheckstate' => 0, 'layout' => '', 'uniqueid' => $this->usage_id($quiz), 'attempt' => 1]); $usertimes[$attemptid] = ['timeclose' => 1201, 'timelimit' => 0, 'message' => 'Test4F', 'time1000state' => 'inprogress']; $quiz = $quizgenerator->create_instance(['course' => $course->id, 'timeclose' => 1200, 'timelimit' => 600, 'overduehandling' => 'autoabandon']); $DB->insert_record('quiz_overrides', ['quiz' => $quiz->id, 'groupid' => $group1->id, 'timeclose' => 1300, 'timelimit' => 700]); $DB->insert_record('quiz_overrides', ['quiz' => $quiz->id, 'userid' => $user1->id, 'timeclose' => null, 'timelimit' => 601]); $attemptid = $DB->insert_record('quiz_attempts', ['quiz' => $quiz->id, 'userid' => $user1->id, 'state' => 'inprogress', 'timestart' => 100, 'timecheckstate' => 0, 'layout' => '', 'uniqueid' => $this->usage_id($quiz)]); $usertimes[$attemptid] = ['timeclose' => 1300, 'timelimit' => 601, 'message' => 'Test4G', 'time1000state' => 'abandoned']; $attemptid = $DB->insert_record('quiz_attempts', ['quiz' => $quiz->id, 'userid' => $user1->id, 'state' => 'inprogress', 'timestart' => 1000, 'timecheckstate' => 0, 'layout' => '', 'uniqueid' => $this->usage_id($quiz), 'attempt' => 1]); $usertimes[$attemptid] = ['timeclose' => 1300, 'timelimit' => 601, 'message' => 'Test4H', 'time1000state' => 'inprogress']; $quiz = $quizgenerator->create_instance(['course' => $course->id, 'timeclose' => 1200, 'timelimit' => 600]); $DB->insert_record('quiz_overrides', ['quiz' => $quiz->id, 'groupid' => $group1->id, 'timeclose' => null, 'timelimit' => 700]); $DB->insert_record('quiz_overrides', ['quiz' => $quiz->id, 'userid' => $user1->id, 'timeclose' => null, 'timelimit' => 601]); $attemptid = $DB->insert_record('quiz_attempts', ['quiz' => $quiz->id, 'userid' => $user1->id, 'state' => 'inprogress', 'timestart' => 100, 'timecheckstate' => 0, 'layout' => '', 'uniqueid' => $this->usage_id($quiz)]); $usertimes[$attemptid] = ['timeclose' => 1200, 'timelimit' => 601, 'message' => 'Test4I', 'time1000state' => 'finished']; $attemptid = $DB->insert_record('quiz_attempts', ['quiz' => $quiz->id, 'userid' => $user1->id, 'state' => 'inprogress', 'timestart' => 1000, 'timecheckstate' => 0, 'layout' => '', 'uniqueid' => $this->usage_id($quiz), 'attempt' => 1]); $usertimes[$attemptid] = ['timeclose' => 1200, 'timelimit' => 601, 'message' => 'Test4J', 'time1000state' => 'inprogress']; $quiz = $quizgenerator->create_instance(['course' => $course->id, 'timeclose' => 1200, 'timelimit' => 600]); $DB->insert_record('quiz_overrides', ['quiz' => $quiz->id, 'groupid' => $group1->id, 'timeclose' => 1300, 'timelimit' => 700]); $DB->insert_record('quiz_overrides', ['quiz' => $quiz->id, 'userid' => $user1->id, 'timeclose' => 1201, 'timelimit' => null]); $attemptid = $DB->insert_record('quiz_attempts', ['quiz' => $quiz->id, 'userid' => $user1->id, 'state' => 'inprogress', 'timestart' => 100, 'timecheckstate' => 0, 'layout' => '', 'uniqueid' => $this->usage_id($quiz)]); $usertimes[$attemptid] = ['timeclose' => 1201, 'timelimit' => 700, 'message' => 'Test4K', 'time1000state' => 'finished']; $attemptid = $DB->insert_record('quiz_attempts', ['quiz' => $quiz->id, 'userid' => $user1->id, 'state' => 'inprogress', 'timestart' => 1000, 'timecheckstate' => 0, 'layout' => '', 'uniqueid' => $this->usage_id($quiz), 'attempt' => 1]); $usertimes[$attemptid] = ['timeclose' => 1201, 'timelimit' => 700, 'message' => 'Test4L', 'time1000state' => 'inprogress']; $quiz = $quizgenerator->create_instance(['course' => $course->id, 'timeclose' => 1200, 'timelimit' => 600]); $DB->insert_record('quiz_overrides', ['quiz' => $quiz->id, 'groupid' => $group1->id, 'timeclose' => 1300, 'timelimit' => null]); $DB->insert_record('quiz_overrides', ['quiz' => $quiz->id, 'userid' => $user1->id, 'timeclose' => 1201, 'timelimit' => null]); $attemptid = $DB->insert_record('quiz_attempts', ['quiz' => $quiz->id, 'userid' => $user1->id, 'state' => 'inprogress', 'timestart' => 100, 'timecheckstate' => 0, 'layout' => '', 'uniqueid' => $this->usage_id($quiz)]); $usertimes[$attemptid] = ['timeclose' => 1201, 'timelimit' => 600, 'message' => 'Test4M', 'time1000state' => 'finished']; $attemptid = $DB->insert_record('quiz_attempts', ['quiz' => $quiz->id, 'userid' => $user1->id, 'state' => 'inprogress', 'timestart' => 1000, 'timecheckstate' => 0, 'layout' => '', 'uniqueid' => $this->usage_id($quiz), 'attempt' => 1]); $usertimes[$attemptid] = ['timeclose' => 1201, 'timelimit' => 600, 'message' => 'Test4N', 'time1000state' => 'inprogress']; $quiz = $quizgenerator->create_instance(['course' => $course->id, 'timeclose' => 1200, 'timelimit' => 600]); $DB->insert_record('quiz_overrides', ['quiz' => $quiz->id, 'groupid' => $group1->id, 'timeclose' => 1300, 'timelimit' => 700]); $DB->insert_record('quiz_overrides', ['quiz' => $quiz->id, 'userid' => 0, 'timeclose' => 1201, 'timelimit' => 601]); // Not user. $attemptid = $DB->insert_record('quiz_attempts', ['quiz' => $quiz->id, 'userid' => $user1->id, 'state' => 'inprogress', 'timestart' => 100, 'timecheckstate' => 0, 'layout' => '', 'uniqueid' => $this->usage_id($quiz)]); $usertimes[$attemptid] = ['timeclose' => 1300, 'timelimit' => 700, 'message' => 'Test4O', 'time1000state' => 'finished']; $attemptid = $DB->insert_record('quiz_attempts', ['quiz' => $quiz->id, 'userid' => $user1->id, 'state' => 'inprogress', 'timestart' => 1000, 'timecheckstate' => 0, 'layout' => '', 'uniqueid' => $this->usage_id($quiz), 'attempt' => 1]); $usertimes[$attemptid] = ['timeclose' => 1300, 'timelimit' => 700, 'message' => 'Test4P', 'time1000state' => 'inprogress']; // Attempt state overdue $quiz = $quizgenerator->create_instance(['course' => $course->id, 'timeclose' => 1200, 'timelimit' => 600, 'overduehandling' => 'graceperiod', 'graceperiod' => 250]); $attemptid = $DB->insert_record('quiz_attempts', ['quiz' => $quiz->id, 'userid' => $user1->id, 'state' => 'overdue', 'timestart' => 100, 'timecheckstate' => 0, 'layout' => '', 'uniqueid' => $this->usage_id($quiz)]); $usertimes[$attemptid] = ['timeclose' => 1200, 'timelimit' => 600, 'message' => 'Test5A', 'time1000state' => 'overdue']; $quiz = $quizgenerator->create_instance(['course' => $course->id, 'timeclose' => 0, 'timelimit' => 600, 'overduehandling' => 'graceperiod', 'graceperiod' => 250]); $attemptid = $DB->insert_record('quiz_attempts', ['quiz' => $quiz->id, 'userid' => $user1->id, 'state' => 'overdue', 'timestart' => 100, 'timecheckstate' => 0, 'layout' => '', 'uniqueid' => $this->usage_id($quiz)]); $usertimes[$attemptid] = ['timeclose' => 0, 'timelimit' => 600, 'message' => 'Test5B', 'time1000state' => 'overdue']; // Compute expected end time for each attempt. foreach ($usertimes as $attemptid => $times) { $attempt = $DB->get_record('quiz_attempts', ['id' => $attemptid], '*', MUST_EXIST); if ($times['timeclose'] > 0 && $times['timelimit'] > 0) { $usertimes[$attemptid]['timedue'] = min($times['timeclose'], $attempt->timestart + $times['timelimit']); } else if ($times['timeclose'] > 0) { $usertimes[$attemptid]['timedue'] = $times['timeclose']; } else if ($times['timelimit'] > 0) { $usertimes[$attemptid]['timedue'] = $attempt->timestart + $times['timelimit']; } } // // Test quiz_update_open_attempts(). // quiz_update_open_attempts(['courseid' => $course->id]); foreach ($usertimes as $attemptid => $times) { $attempt = $DB->get_record('quiz_attempts', ['id' => $attemptid], '*', MUST_EXIST); if ($attempt->state == 'overdue') { $graceperiod = $DB->get_field('quiz', 'graceperiod', ['id' => $attempt->quiz]); } else { $graceperiod = 0; } if (isset($times['timedue'])) { $this->assertEquals($times['timedue'] + $graceperiod, $attempt->timecheckstate, $times['message']); } else { $this->assertNull($attempt->timecheckstate, $times['message']); } } // // Test get_list_of_overdue_attempts(). // $overduehander = new update_overdue_attempts(); $attempts = $overduehander->get_list_of_overdue_attempts(100000); // way in the future $count = 0; foreach ($attempts as $attempt) { $this->assertTrue(isset($usertimes[$attempt->id])); $times = $usertimes[$attempt->id]; $this->assertEquals($times['timeclose'], $attempt->usertimeclose, $times['message']); $this->assertEquals($times['timelimit'], $attempt->usertimelimit, $times['message']); $count++; } $attempts->close(); $this->assertEquals($DB->count_records_select('quiz_attempts', 'timecheckstate IS NOT NULL'), $count); $attempts = $overduehander->get_list_of_overdue_attempts(0); // before all attempts $count = 0; foreach ($attempts as $attempt) { $count++; } $attempts->close(); $this->assertEquals(0, $count); // // Test update_overdue_attempts(). // [$count, $quizcount] = $overduehander->update_all_overdue_attempts(1000, 940); $attempts = $DB->get_records('quiz_attempts', null, 'quiz, userid, attempt', 'id, quiz, userid, attempt, state, timestart, timefinish, timecheckstate'); foreach ($attempts as $attempt) { $this->assertTrue(isset($usertimes[$attempt->id])); $times = $usertimes[$attempt->id]; $this->assertEquals($times['time1000state'], $attempt->state, $times['message']); switch ($times['time1000state']) { case 'finished': $this->assertEquals($times['timedue'], $attempt->timefinish, $times['message']); $this->assertNull($attempt->timecheckstate, $times['message']); break; case 'overdue': $this->assertEquals(0, $attempt->timefinish, $times['message']); $graceperiod = $DB->get_field('quiz', 'graceperiod', ['id' => $attempt->quiz]); $this->assertEquals($times['timedue'] + $graceperiod, $attempt->timecheckstate, $times['message']); break; case 'abandoned': $this->assertEquals(0, $attempt->timefinish, $times['message']); $this->assertNull($attempt->timecheckstate, $times['message']); break; } } $this->assertEquals(19, $count); $this->assertEquals(19, $quizcount); } /** * Make any old question usage for a quiz. * * The attempts used in test_bulk_update_functions must have some * question usage to store in uniqueid, but they don't have to be * very realistic. * * @param \stdClass $quiz * @return int question usage id. */ protected function usage_id(\stdClass $quiz): int { $quba = question_engine::make_questions_usage_by_activity('mod_quiz', \context_module::instance($quiz->cmid)); $quba->set_preferred_behaviour('deferredfeedback'); question_engine::save_questions_usage_by_activity($quba); return $quba->get_id(); } /** * Test the group event handlers */ public function test_group_event_handlers(): void { global $DB; $this->resetAfterTest(); $this->setAdminUser(); // Setup course, user and groups $course = $this->getDataGenerator()->create_course(); $user1 = $this->getDataGenerator()->create_user(); $studentrole = $DB->get_record('role', ['shortname' => 'student']); $this->assertNotEmpty($studentrole); $this->assertTrue(enrol_try_internal_enrol($course->id, $user1->id, $studentrole->id)); $group1 = $this->getDataGenerator()->create_group(['courseid' => $course->id]); $group2 = $this->getDataGenerator()->create_group(['courseid' => $course->id]); $this->assertTrue(groups_add_member($group1, $user1)); $this->assertTrue(groups_add_member($group2, $user1)); /** @var mod_quiz_generator $quizgenerator */ $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz'); $quiz = $quizgenerator->create_instance(['course' => $course->id, 'timeclose' => 1200, 'timelimit' => 0]); // add a group1 override $DB->insert_record('quiz_overrides', ['quiz' => $quiz->id, 'groupid' => $group1->id, 'timeclose' => 1300, 'timelimit' => null]); // add an attempt $attemptid = $DB->insert_record('quiz_attempts', ['quiz' => $quiz->id, 'userid' => $user1->id, 'state' => 'inprogress', 'timestart' => 100, 'timecheckstate' => 0, 'layout' => '', 'uniqueid' => $this->usage_id($quiz)]); // update timecheckstate quiz_update_open_attempts(['quizid' => $quiz->id]); $this->assertEquals(1300, $DB->get_field('quiz_attempts', 'timecheckstate', ['id' => $attemptid])); // remove from group $this->assertTrue(groups_remove_member($group1, $user1)); $this->assertEquals(1200, $DB->get_field('quiz_attempts', 'timecheckstate', ['id' => $attemptid])); // add back to group $this->assertTrue(groups_add_member($group1, $user1)); $this->assertEquals(1300, $DB->get_field('quiz_attempts', 'timecheckstate', ['id' => $attemptid])); // delete group groups_delete_group($group1); $this->assertEquals(1200, $DB->get_field('quiz_attempts', 'timecheckstate', ['id' => $attemptid])); $this->assertEquals(0, $DB->count_records('quiz_overrides', ['quiz' => $quiz->id])); // add a group2 override $DB->insert_record('quiz_overrides', ['quiz' => $quiz->id, 'groupid' => $group2->id, 'timeclose' => 1400, 'timelimit' => null]); quiz_update_open_attempts(['quizid' => $quiz->id]); $this->assertEquals(1400, $DB->get_field('quiz_attempts', 'timecheckstate', ['id' => $attemptid])); // delete user1 from all groups groups_delete_group_members($course->id, $user1->id); $this->assertEquals(1200, $DB->get_field('quiz_attempts', 'timecheckstate', ['id' => $attemptid])); // add back to group2 $this->assertTrue(groups_add_member($group2, $user1)); $this->assertEquals(1400, $DB->get_field('quiz_attempts', 'timecheckstate', ['id' => $attemptid])); // delete everyone from all groups groups_delete_group_members($course->id); $this->assertEquals(1200, $DB->get_field('quiz_attempts', 'timecheckstate', ['id' => $attemptid])); } /** * Test the functions quiz_create_attempt_handling_errors */ public function test_quiz_create_attempt_handling_errors(): void { $this->resetAfterTest(true); $this->setAdminUser(); // Make a quiz. $course = $this->getDataGenerator()->create_course(); $user1 = $this->getDataGenerator()->create_user(); $student = $this->getDataGenerator()->create_user(); $this->getDataGenerator()->enrol_user($student->id, $course->id, 'student'); /** @var mod_quiz_generator $quizgenerator */ $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz'); /** @var core_question_generator $questiongenerator */ $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); $quiz = $quizgenerator->create_instance(['course' => $course->id, 'questionsperpage' => 0, 'grade' => 100.0, 'sumgrades' => 2]); // Create questions. $cat = $questiongenerator->create_question_category(); $saq = $questiongenerator->create_question('shortanswer', null, ['category' => $cat->id]); $numq = $questiongenerator->create_question('numerical', null, ['category' => $cat->id]); // Add them to the quiz. quiz_add_quiz_question($saq->id, $quiz); quiz_add_quiz_question($numq->id, $quiz); $quizobj = quiz_settings::create($quiz->id, $user1->id); $quba = question_engine::make_questions_usage_by_activity('mod_quiz', $quizobj->get_context()); $quba->set_preferred_behaviour($quizobj->get_quiz()->preferredbehaviour); $timenow = time(); // Create an attempt. $attempt = quiz_create_attempt($quizobj, 1, null, $timenow, false, $user1->id); quiz_start_new_attempt($quizobj, $quba, $attempt, 1, $timenow); quiz_attempt_save_started($quizobj, $quba, $attempt); $result = quiz_create_attempt_handling_errors($attempt->id, $quiz->cmid); $this->assertEquals($result->get_attemptid(), $attempt->id); try { $result = quiz_create_attempt_handling_errors($attempt->id, 9999); $this->fail('Exception expected due to invalid course module id.'); } catch (\moodle_exception $e) { $this->assertEquals('invalidcoursemodule', $e->errorcode); } try { quiz_create_attempt_handling_errors(9999, $result->get_cmid()); $this->fail('Exception expected due to quiz content change.'); } catch (\moodle_exception $e) { $this->assertEquals('attempterrorcontentchange', $e->errorcode); } try { quiz_create_attempt_handling_errors(9999); $this->fail('Exception expected due to invalid quiz attempt id.'); } catch (\moodle_exception $e) { $this->assertEquals('attempterrorinvalid', $e->errorcode); } // Set up as normal user without permission to view preview. $this->setUser($student->id); try { quiz_create_attempt_handling_errors(9999, $result->get_cmid()); $this->fail('Exception expected due to quiz content change for user without permission.'); } catch (\moodle_exception $e) { $this->assertEquals('attempterrorcontentchangeforuser', $e->errorcode); } try { quiz_create_attempt_handling_errors($attempt->id, 9999); $this->fail('Exception expected due to invalid course module id for user without permission.'); } catch (\moodle_exception $e) { $this->assertEquals('invalidcoursemodule', $e->errorcode); } } /** * Confirm that update_overdue_attempts refetches the attempt from the DB and does not act on stale data. */ public function test_update_overdue_attempts_refetches_attempts_before_processing(): void { global $DB; $this->resetAfterTest(true); $this->setAdminUser(); $course = $this->getDataGenerator()->create_course(); $student1 = $this->getDataGenerator()->create_user(); $student2 = $this->getDataGenerator()->create_user(); $studentrole = $DB->get_record('role', ['shortname' => 'student']); $this->assertTrue(enrol_try_internal_enrol($course->id, $student1->id, $studentrole->id)); $this->assertTrue(enrol_try_internal_enrol($course->id, $student2->id, $studentrole->id)); $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz'); $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); $quiz = $quizgenerator->create_instance([ 'course' => $course->id, 'overduehandling' => 'autosubmit', 'timelimit' => 1, ]); $category = $questiongenerator->create_question_category(); $question = $questiongenerator->create_question('truefalse', null, ['category' => $category->id]); $quizobj = quiz_settings::create($quiz->id); quiz_add_quiz_question($question->id, $quiz); $quizobj->get_grade_calculator()->recompute_quiz_sumgrades(); // Create one attempt that is already finished and one that cron should submit. $this->setUser($student1); $finishedattempt = $quizgenerator->create_attempt($quiz->id, $student1->id); $finishedattemptid = $finishedattempt->id; $this->setUser($student2); $overdueattempt = $quizgenerator->create_attempt($quiz->id, $student2->id); $overdueattemptid = $overdueattempt->id; $finishedtime = $finishedattempt->timestart + 10; $DB->update_record('quiz_attempts', (object)[ 'id' => $finishedattemptid, 'state' => 'finished', 'timemodified' => $finishedtime, 'timefinish' => $finishedtime, 'timecheckstate' => null, ]); $attemptsinfo = [ (object)[ 'id' => $finishedattemptid, 'quiz' => $quiz->id, 'usertimeclose' => 0, 'usertimelimit' => 1, ], (object)[ 'id' => $overdueattemptid, 'quiz' => $quiz->id, 'usertimeclose' => 0, 'usertimelimit' => 1, ], ]; $originaloverdueattempt = $DB->get_record('quiz_attempts', ['id' => $overdueattemptid]); $this->assertEquals('inprogress', $originaloverdueattempt->state); $recordset = new class ($attemptsinfo) extends moodle_recordset { /** @var array */ private array $records; /** @var int */ private int $position = 0; /** * Constructor. * * @param array $records records to iterate over. */ public function __construct(array $records) { $this->records = array_values($records); } /** * Get the current record. * * @return mixed */ #[\Override] public function current(): mixed { return $this->records[$this->position] ?? false; } /** * Get the current key. * * @return mixed */ #[\Override] public function key(): mixed { return $this->position; } /** * Move to the next record. */ #[\Override] public function next(): void { $this->position++; } /** * Whether the current position is valid. */ #[\Override] public function valid(): bool { return isset($this->records[$this->position]); } /** * Close the recordset. */ #[\Override] public function close() { $this->records = []; $this->position = 0; } }; $this->setAdminUser(); $overduetask = new class ($recordset) extends update_overdue_attempts { /** @var moodle_recordset */ private moodle_recordset $recordset; /** * Constructor. * * @param moodle_recordset $recordset recordset to return. */ public function __construct(moodle_recordset $recordset) { $this->recordset = $recordset; } /** * Return the recordset of overdue attempts. * * @param int $processto timestamp to process up to. * @return moodle_recordset */ #[\Override] public function get_list_of_overdue_attempts(int $processto): moodle_recordset { return $this->recordset; } }; $timenow = $overdueattempt->timestart + 100; $overduetask->update_all_overdue_attempts($timenow, 0); $latestfinishedattempt = $DB->get_record('quiz_attempts', ['id' => $finishedattemptid]); $this->assertEquals($finishedtime, $latestfinishedattempt->timefinish); $this->assertEquals($finishedtime, $latestfinishedattempt->timemodified); $this->assertEquals('finished', $latestfinishedattempt->state); $latestoverdueattempt = $DB->get_record('quiz_attempts', ['id' => $overdueattemptid]); $this->assertEquals('finished', $latestoverdueattempt->state); $this->assertGreaterThan(0, $latestoverdueattempt->timefinish); $this->assertNotEquals(0, $latestoverdueattempt->timemodified); } } PK9B\f'1tests/privacy_legacy_quizaccess_polyfill_test.phpnu[. /** * Unit tests for the privacy legacy polyfill for quiz access rules. * * @copyright 2018 Andrew Nicols * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace mod_quiz; /** * Unit tests for the privacy legacy polyfill for quiz access rules. * * @copyright 2018 Andrew Nicols * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class privacy_legacy_quizaccess_polyfill_test extends \advanced_testcase { /** * Test that the core_quizaccess\privacy\legacy_polyfill works and that the static _export_quizaccess_user_data can * be called. */ public function test_export_quizaccess_user_data(): void { $quiz = $this->createMock(quiz_settings::class); $user = (object) []; $returnvalue = (object) []; $mock = $this->createMock(test_privacy_legacy_quizaccess_polyfill_mock_wrapper::class); $mock->expects($this->once()) ->method('get_return_value') ->with('_export_quizaccess_user_data', [$quiz, $user]) ->willReturn($returnvalue); test_privacy_legacy_quizaccess_polyfill_provider::$mock = $mock; $result = test_privacy_legacy_quizaccess_polyfill_provider::export_quizaccess_user_data($quiz, $user); $this->assertSame($returnvalue, $result); } /** * Test the _delete_quizaccess_for_context shim. */ public function test_delete_quizaccess_for_context(): void { $context = \context_system::instance(); $quiz = $this->createMock(quiz_settings::class); $mock = $this->createMock(test_privacy_legacy_quizaccess_polyfill_mock_wrapper::class); $mock->expects($this->once()) ->method('get_return_value') ->with('_delete_quizaccess_data_for_all_users_in_context', [$quiz]); test_privacy_legacy_quizaccess_polyfill_provider::$mock = $mock; test_privacy_legacy_quizaccess_polyfill_provider::delete_quizaccess_data_for_all_users_in_context($quiz); } /** * Test the _delete_quizaccess_for_user shim. */ public function test_delete_quizaccess_for_user(): void { $context = \context_system::instance(); $quiz = $this->createMock(quiz_settings::class); $user = (object) []; $mock = $this->createMock(test_privacy_legacy_quizaccess_polyfill_mock_wrapper::class); $mock->expects($this->once()) ->method('get_return_value') ->with('_delete_quizaccess_data_for_user', [$quiz, $user]); test_privacy_legacy_quizaccess_polyfill_provider::$mock = $mock; test_privacy_legacy_quizaccess_polyfill_provider::delete_quizaccess_data_for_user($quiz, $user); } /** * Test the _delete_quizaccess_for_users shim. */ public function test_delete_quizaccess_for_users(): void { $context = $this->createMock(\context_module::class); $user = (object) []; $approveduserlist = new \core_privacy\local\request\approved_userlist($context, 'mod_quiz', [$user]); $mock = $this->createMock(test_privacy_legacy_quizaccess_polyfill_mock_wrapper::class); $mock->expects($this->once()) ->method('get_return_value') ->with('_delete_quizaccess_data_for_users', [$approveduserlist]); test_privacy_legacy_quizaccess_polyfill_provider::$mock = $mock; test_privacy_legacy_quizaccess_polyfill_provider::delete_quizaccess_data_for_users($approveduserlist); } } /** * Legacy polyfill test class for the quizaccess_provider. * * @copyright 2018 Andrew Nicols * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class test_privacy_legacy_quizaccess_polyfill_provider implements \core_privacy\local\metadata\provider, \mod_quiz\privacy\quizaccess_provider, \mod_quiz\privacy\quizaccess_user_provider { use \mod_quiz\privacy\legacy_quizaccess_polyfill; use \core_privacy\local\legacy_polyfill; /** * @var test_privacy_legacy_quizaccess_polyfill_provider $mock. */ public static $mock = null; /** * Export all user data for the quizaccess plugin. * * @param \mod_quiz\quiz_settings $quiz * @param \stdClass $user */ protected static function _export_quizaccess_user_data($quiz, $user) { return static::$mock->get_return_value(__FUNCTION__, func_get_args()); } /** * Deletes all user data for the given context. * * @param \mod_quiz\quiz_settings $quiz */ protected static function _delete_quizaccess_data_for_all_users_in_context($quiz) { static::$mock->get_return_value(__FUNCTION__, func_get_args()); } /** * Delete personal data for the given user and context. * * @param \mod_quiz\quiz_settings $quiz The quiz being deleted * @param \stdClass $user The user to export data for */ protected static function _delete_quizaccess_data_for_user($quiz, $user) { static::$mock->get_return_value(__FUNCTION__, func_get_args()); } /** * Delete all user data for the specified users, in the specified context. * * @param \core_privacy\local\request\approved_userlist $userlist */ protected static function _delete_quizaccess_data_for_users($userlist) { static::$mock->get_return_value(__FUNCTION__, func_get_args()); } /** * Returns metadata about this plugin. * * @param \core_privacy\local\metadata\collection $collection The initialised collection to add items to. * @return \core_privacy\local\metadata\collection A listing of user data stored through this system. */ protected static function _get_metadata(\core_privacy\local\metadata\collection $collection) { return $collection; } } /** * Called inside the polyfill methods in the test polyfill provider, allowing us to ensure these are called with correct params. * * @copyright 2018 Andrew Nicols * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class test_privacy_legacy_quizaccess_polyfill_mock_wrapper { /** * Get the return value for the specified item. */ public function get_return_value() { } } PK9B\)tests/quiz_question_helper_test_trait.phpnu[. /** * Trait helper. * * @package mod_quiz * @category test * @copyright 2013 The Open University * @author Jamie Pratt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class_alias(\mod_quiz\tests\question_helper_test_trait::class, \quiz_question_helper_test_trait::class); PK9B\"tests/generator_test.phpnu[. namespace mod_quiz; /** * PHPUnit data generator testcase * * @package mod_quiz * @category phpunit * @copyright 2012 Matt Petro * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @covers \mod_quiz_generator */ final class generator_test extends \advanced_testcase { public function test_generator(): void { global $DB, $SITE; $this->resetAfterTest(true); $this->assertEquals(0, $DB->count_records('quiz')); /** @var \mod_quiz_generator $generator */ $generator = $this->getDataGenerator()->get_plugin_generator('mod_quiz'); $this->assertInstanceOf('mod_quiz_generator', $generator); $this->assertEquals('quiz', $generator->get_modulename()); $generator->create_instance(['course' => $SITE->id]); $generator->create_instance(['course' => $SITE->id]); $createtime = time(); $quiz = $generator->create_instance(['course' => $SITE->id, 'timecreated' => 0]); $this->assertEquals(3, $DB->count_records('quiz')); $cm = get_coursemodule_from_instance('quiz', $quiz->id); $this->assertEquals($quiz->id, $cm->instance); $this->assertEquals('quiz', $cm->modname); $this->assertEquals($SITE->id, $cm->course); $context = \context_module::instance($cm->id); $this->assertEquals($quiz->cmid, $context->instanceid); $this->assertEqualsWithDelta($createtime, $DB->get_field('quiz', 'timecreated', ['id' => $cm->instance]), 2); } public function test_generating_a_user_override(): void { $this->resetAfterTest(true); $generator = $this->getDataGenerator(); $course = $generator->create_course(); $user = $generator->create_user(); $quiz = $generator->create_module('quiz', ['course' => $course->id]); $generator->enrol_user($user->id, $course->id, 'student'); /** @var \mod_quiz_generator $quizgenerator */ $quizgenerator = $generator->get_plugin_generator('mod_quiz'); $quizgenerator->create_override([ 'quiz' => $quiz->id, 'userid' => $user->id, 'timeclose' => strtotime('2022-10-20'), ]); // Check the corresponding calendar event now exists. $events = calendar_get_events(strtotime('2022-01-01'), strtotime('2022-12-31'), $user->id, false, $course->id); $this->assertCount(1, $events); $event = reset($events); $this->assertEquals($user->id, $event->userid); $this->assertEquals(0, $event->groupid); $this->assertEquals(0, $event->courseid); $this->assertEquals('quiz', $event->modulename); $this->assertEquals($quiz->id, $event->instance); $this->assertEquals('close', $event->eventtype); $this->assertEquals(strtotime('2022-10-20'), $event->timestart); } public function test_generating_a_group_override(): void { $this->resetAfterTest(true); $generator = $this->getDataGenerator(); $course = $generator->create_course(); $quiz = $generator->create_module('quiz', ['course' => $course->id]); $group = $generator->create_group(['courseid' => $course->id]); /** @var \mod_quiz_generator $quizgenerator */ $quizgenerator = $generator->get_plugin_generator('mod_quiz'); $quizgenerator->create_override([ 'quiz' => $quiz->id, 'groupid' => $group->id, 'timeclose' => strtotime('2022-10-20'), ]); // Check the corresponding calendar event now exists. $events = calendar_get_events(strtotime('2022-01-01'), strtotime('2022-12-31'), false, $group->id, $course->id); $this->assertCount(1, $events); $event = reset($events); $this->assertEquals(0, $event->userid); $this->assertEquals($group->id, $event->groupid); $this->assertEquals($course->id, $event->courseid); $this->assertEquals('quiz', $event->modulename); $this->assertEquals($quiz->id, $event->instance); $this->assertEquals('close', $event->eventtype); $this->assertEquals(strtotime('2022-10-20'), $event->timestart); } public function test_generating_a_grade_item(): void { $this->resetAfterTest(); // Create a quiz to use in the test. $generator = $this->getDataGenerator(); $course = $generator->create_course(); $quiz = $generator->create_module('quiz', ['course' => $course->id]); // Create a grade item. /** @var \mod_quiz_generator $quizgenerator */ $quizgenerator = $generator->get_plugin_generator('mod_quiz'); $newgradeitem = $quizgenerator->create_grade_item([ 'quizid' => $quiz->id, 'name' => 'Awesomeness!', ]); // Verify the grade item was created correctly. $this->assertObjectHasProperty('id', $newgradeitem); $this->assertEquals($quiz->id, $newgradeitem->quizid); $this->assertEquals('Awesomeness!', $newgradeitem->name); } } PK9B\)a445tests/behat/editing_remove_multiple_questions.featurenu[@mod @mod_quiz Feature: Edit quiz page - remove multiple questions In order to change the layout of a quiz I built efficiently As a teacher I need to be able to delete many questions questions. 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 | Quiz 1 | C1 | quiz1 | And the following "question categories" exist: | contextlevel | reference | name | | Activity module | quiz1 | Test questions | And I log in as "teacher1" @javascript Scenario: Delete selected question using select multiple items feature. Given the following "questions" exist: | questioncategory | qtype | name | questiontext | | Test questions | description | Info | Some instructions | | Test questions | truefalse | Question A | This is question 01 | | Test questions | truefalse | Question B | This is question 02 | | Test questions | truefalse | Question C | This is question 03 | And quiz "Quiz 1" contains the following questions: | question | page | | Info | 1 | | Question A | 1 | | Question B | 1 | | Question C | 2 | And I am on the "Quiz 1" "mod_quiz > Edit" page # Confirm the starting point. Then I should see "Question A" on quiz page "1" And I should see "Question B" on quiz page "1" And I should see "Question C" on quiz page "2" And I should see "Total of marks: 3.00" And I should see "Questions: 4" And I should see "This quiz is open" # Delete last question in last page. Page contains multiple questions. No reordering. When I click on "Select multiple items" "button" Then I click on "selectquestion-3" "checkbox" And I click on "Delete selected" "button" And I click on "Yes" "button" in the "Confirm" "dialogue" Then I should see "Info" on quiz page "1" And I should see "Question A" on quiz page "1" And I should not see "Question B" on quiz page "1" And I should see "Question C" on quiz page "2" And I should see "Total of marks: 2.00" And I should see "Questions: 3" @javascript Scenario: Delete first selected question using select multiple items feature. Given the following "questions" exist: | questioncategory | qtype | name | questiontext | | Test questions | truefalse | Question A | This is question 01 | | Test questions | truefalse | Question B | This is question 02 | | Test questions | truefalse | Question C | This is question 03 | And quiz "Quiz 1" contains the following questions: | question | page | | Question A | 1 | | Question B | 2 | | Question C | 2 | And I am on the "Quiz 1" "mod_quiz > Edit" page # Confirm the starting point. Then I should see "Question A" on quiz page "1" And I should see "Question B" on quiz page "2" And I should see "Question C" on quiz page "2" And I should see "Total of marks: 3.00" And I should see "Questions: 3" And I should see "This quiz is open" # Delete first question in first page. Page contains multiple questions. No reordering. When I click on "Select multiple items" "button" Then I click on "selectquestion-1" "checkbox" And I click on "Delete selected" "button" And I click on "Yes" "button" in the "Confirm" "dialogue" Then I should not see "Question A" on quiz page "1" And I should see "Question B" on quiz page "1" And I should see "Question C" on quiz page "1" And I should see "Total of marks: 2.00" And I should see "Questions: 2" @javascript Scenario: Can delete the last question in a quiz. Given the following "questions" exist: | questioncategory | qtype | name | questiontext | | Test questions | truefalse | Question A | This is question 01 | And quiz "Quiz 1" contains the following questions: | question | page | | Question A | 1 | And I am on the "Quiz 1" "mod_quiz > Edit" page When I click on "Select multiple items" "button" And I click on "selectquestion-1" "checkbox" And I click on "Delete selected" "button" And I click on "Yes" "button" in the "Confirm" "dialogue" Then I should see "Questions: 0" @javascript Scenario: Delete all questions by checking select all. Given the following "questions" exist: | questioncategory | qtype | name | questiontext | | Test questions | truefalse | Question A | This is question 01 | | Test questions | truefalse | Question B | This is question 02 | | Test questions | truefalse | Question C | This is question 03 | And quiz "Quiz 1" contains the following questions: | question | page | | Question A | 1 | | Question B | 1 | | Question C | 2 | And I am on the "Quiz 1" "mod_quiz > Edit" page # Confirm the starting point. Then I should see "Question A" on quiz page "1" And I should see "Question B" on quiz page "1" And I should see "Question C" on quiz page "2" And I should see "Total of marks: 3.00" And I should see "Questions: 3" And I should see "This quiz is open" # Delete all questions in page. Page contains multiple questions When I click on "Select multiple items" "button" Then I press "Select all" And I click on "Delete selected" "button" And I click on "Yes" "button" in the "Confirm" "dialogue" Then I should not see "Question A" on quiz page "1" And I should not see "Question B" on quiz page "1" And I should not see "Question C" on quiz page "2" And I should see "Total of marks: 0.00" And I should see "Questions: 0" @javascript Scenario: Deselect all questions by checking deselect all. Given the following "questions" exist: | questioncategory | qtype | name | questiontext | | Test questions | truefalse | Question A | This is question 01 | | Test questions | truefalse | Question B | This is question 02 | | Test questions | truefalse | Question C | This is question 03 | And quiz "Quiz 1" contains the following questions: | question | page | | Question A | 1 | | Question B | 1 | | Question C | 2 | And I am on the "Quiz 1" "mod_quiz > Edit" page # Confirm the starting point. Then I should see "Question A" on quiz page "1" And I should see "Question B" on quiz page "1" And I should see "Question C" on quiz page "2" # Delete last question in last page. Page contains multiple questions When I click on "Select multiple items" "button" And I press "Select all" Then the field "selectquestion-3" matches value "1" When I press "Deselect all" Then the field "selectquestion-3" matches value "0" @javascript Scenario: Delete multiple questions from sections Given the following "questions" exist: | questioncategory | qtype | name | questiontext | | Test questions | truefalse | Question A | First question | | Test questions | truefalse | Question B | Second question | | Test questions | truefalse | Question C | Third question | | Test questions | truefalse | Question D | Fourth question | | Test questions | truefalse | Question E | Fifth question | | Test questions | truefalse | Question F | Sixth question | And quiz "Quiz 1" contains the following questions: | question | page | | Question A | 1 | | Question B | 2 | | Question C | 3 | | Question D | 4 | | Question E | 5 | | Question F | 6 | And quiz "Quiz 1" contains the following sections: | heading | firstslot | shuffle | | Section 1 | 1 | 0 | | Section 2 | 2 | 0 | | Section 3 | 4 | 0 | And I am on the "Quiz 1" "mod_quiz > Edit" page When I click on "Select multiple items" "button" And I click on "selectquestion-3" "checkbox" And I click on "selectquestion-5" "checkbox" And I click on "selectquestion-6" "checkbox" And I click on "Delete selected" "button" And I click on "Yes" "button" in the "Confirm" "dialogue" Then I should see "Question A" on quiz page "1" And I should see "Question B" on quiz page "2" And I should see "Question D" on quiz page "3" And I should not see "Question C" And I should not see "Question E" And I should not see "Question F" @javascript Scenario: Attempting to delete all questions of a sections Given the following "questions" exist: | questioncategory | qtype | name | questiontext | | Test questions | truefalse | Question A | First question | | Test questions | truefalse | Question B | Second question | | Test questions | truefalse | Question C | Third question | | Test questions | truefalse | Question D | Fourth question | | Test questions | truefalse | Question E | Fifth question | | Test questions | truefalse | Question F | Sixth question | And quiz "Quiz 1" contains the following questions: | question | page | | Question A | 1 | | Question B | 2 | | Question C | 3 | | Question D | 4 | | Question E | 5 | | Question F | 6 | And quiz "Quiz 1" contains the following sections: | heading | firstslot | shuffle | | Section 1 | 1 | 0 | | Section 2 | 2 | 0 | | Section 3 | 4 | 0 | And I am on the "Quiz 1" "mod_quiz > Edit" page When I click on "Select multiple items" "button" And I click on "selectquestion-2" "checkbox" And I click on "selectquestion-3" "checkbox" And I click on "Delete selected" "button" Then I should see "Cannot remove questions" @javascript Scenario: Delete multiple random questions from sections. Given the following "questions" exist: | questioncategory | qtype | name | questiontext | | Test questions | truefalse | Question A | First question | | Test questions | truefalse | Question B | Second question | | Test questions | truefalse | Question C | Third question | | Test questions | truefalse | Question D | Fourth question | | Test questions | truefalse | Question E | Fifth question | | Test questions | truefalse | Question F | Sixth question | And I am on the "Quiz 1" "mod_quiz > Edit" page When I open the "last" add to quiz menu And I follow "a random question" And I apply question bank filter "Category" with value "Test questions" And I set the field "Number of random questions" to "3" And I press "Add random question" And I click on "Select multiple items" "button" And I click on "selectquestion-1" "checkbox" And I click on "selectquestion-2" "checkbox" And I click on "Delete selected" "button" And I click on "Yes" "button" in the "Confirm" "dialogue" Then I should see "Random (Test questions) based on filter condition" on quiz page "1" And I should not see "Random (Test questions) based on filter condition" on quiz page "2" And I should not see "Random (Test questions) based on filter condition" on quiz page "3" And I should see "Total of marks: 1.00" And I should see "Questions: 1" @javascript Scenario: Delete all random questions by checking select all. Given the following "questions" exist: | questioncategory | qtype | name | questiontext | | Test questions | truefalse | Question A | First question | | Test questions | truefalse | Question B | Second question | | Test questions | truefalse | Question C | Third question | | Test questions | truefalse | Question D | Fourth question | | Test questions | truefalse | Question E | Fifth question | | Test questions | truefalse | Question F | Sixth question | And I am on the "Quiz 1" "mod_quiz > Edit" page # Delete all questions in page. Page contains multiple questions. When I open the "last" add to quiz menu And I follow "a random question" And I set the field "Number of random questions" to "3" And I press "Add random question" And I click on "Select multiple items" "button" And I press "Select all" And I click on "Delete selected" "button" And I click on "Yes" "button" in the "Confirm" "dialogue" Then I should not see "Random question based on filter condition" on quiz page "1" And I should not see "Random question based on filter condition" on quiz page "2" And I should not see "Random question based on filter condition" on quiz page "3" And I should see "Total of marks: 0.00" And I should see "Questions: 0" PK9B\ 3tests/behat/quiz_activity_completion_locked.featurenu[@mod @mod_quiz @core_completion Feature: Ensure saving a quiz does not modify the completion settings. In order to reliably use completion As a teacher I need to be able to update the quiz without changing the completion settings. Background: Given the following "users" exist: | username | firstname | lastname | email | | student1 | Student | 1 | student1@example.com | | teacher1 | Teacher | 1 | teacher1@example.com | And the following "courses" exist: | fullname | shortname | category | enablecompletion | | Course 1 | C1 | 0 | 1 | And the following "course enrolments" exist: | user | course | role | | teacher1 | C1 | editingteacher | | student1 | C1 | student | And the following "activity" exists: | activity | quiz | | course | C1 | | idnumber | quiz1 | | name | Test quiz | | section | 1 | | attempts | 2 | | gradepass | 5.00 | | completion | 2 | | completionview | 0 | | completionusegrade | 1 | | completionpassgrade | 1 | | completionattemptsexhausted | 1 | 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 | And quiz "Test quiz" contains the following questions: | question | page | | First question | 1 | And user "student1" has attempted "Test quiz" with responses: | slot | response | | 1 | True | Scenario: Ensure saving quiz activty does not change completion settings Given I am on the "Test quiz" "mod_quiz > View" page logged in as "teacher1" When I navigate to "Settings" in current page administration Then the "completionattemptsexhausted" "field" should be disabled And the field "completionattemptsexhausted" matches value "1" And I press "Save and display" And I navigate to "Settings" in current page administration And the "completionattemptsexhausted" "field" should be disabled And the field "completionattemptsexhausted" matches value "1" PK9B\awn6tests/behat/completion_condition_attempts_used.featurenu[@mod @mod_quiz @core_completion @javascript Feature: Set a quiz to be marked complete when the student uses all attempts allowed In order to ensure a student has learned the material before being marked complete As a teacher I need to set a quiz to complete when the student receives a passing grade, or completed_fail if they use all attempts without passing Background: Given the following "users" exist: | username | firstname | lastname | email | | student1 | Student | 1 | student1@example.com | | teacher1 | Teacher | 1 | teacher1@example.com | And the following "courses" exist: | fullname | shortname | category | enablecompletion | | Course 1 | C1 | 0 | 1 | And the following "course enrolments" exist: | user | course | role | | teacher1 | C1 | editingteacher | | student1 | C1 | student | And the following "activities" exist: | activity | name | course | idnumber | attempts | gradepass | completion | completionusegrade | completionpassgrade | completionattemptsexhausted | | quiz | Test quiz name | C1 | quiz1 | 2 | 5.00 | 2 | 1 | 1 | 1 | 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 | And quiz "Test quiz name" contains the following questions: | question | page | | First question | 1 | And user "student1" has attempted "Test quiz name" with responses: | slot | response | | 1 | False | Scenario Outline: Student attempts the quiz - pass and fails When I am on the "Course 1" course page logged in as student1 And the "Receive a grade" completion condition of "Test quiz name" is displayed as "done" And the "Receive a passing grade" completion condition of "Test quiz name" is displayed as "failed" And the "Receive a pass grade or complete all available attempts" completion condition of "Test quiz name" is displayed as "todo" And I follow "Test quiz name" And I press "Re-attempt quiz" And I set the field "" to "1" And I press "Finish attempt ..." And I press "Submit all and finish" And I click on "Submit all and finish" "button" in the "Submit all your answers and finish?" "dialogue" And I am on "Course 1" course homepage Then the "Receive a grade" completion condition of "Test quiz name" is displayed as "done" And the "Receive a passing grade" completion condition of "Test quiz name" is displayed as "" And the "Receive a pass grade or complete all available attempts" completion condition of "Test quiz name" is displayed as "done" And I click on "Test quiz name" "link" in the "region-main" "region" And the "Receive a grade" completion condition of "Test quiz name" is displayed as "done" And the "Receive a passing grade" completion condition of "Test quiz name" is displayed as "" And the "Receive a pass grade or complete all available attempts" completion condition of "Test quiz name" is displayed as "done" And I log out And I am on the "Test quiz name" "quiz activity" page logged in as teacher1 And "Test quiz name" should have the "Receive a pass grade or complete all available attempts" completion condition And I am on "Course 1" course homepage And I navigate to "Reports" in current page administration And I click on "Activity completion" "link" And "" "icon" should exist in the "Student 1" "table_row" Examples: | answer | passcompletionexpected | expectedactivitycompletion | | False | failed | Completed (did not achieve pass grade) | | True | done | Completed (achieved pass grade) | PK9B\ԅ!tests/behat/attempt_begin.featurenu[@mod @mod_quiz Feature: The various checks that may happen when an attept is started As a student In order to start a quiz with confidence I need to be waned if there is a time limit, or various similar things Background: Given the following "users" exist: | username | firstname | lastname | email | | student | Student | One | student@example.com | And 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 "course enrolments" exist: | user | course | role | | student | C1 | student | 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 | TF1 | Text of the first question | @javascript Scenario: Start a quiz with no time limit Given the following "activities" exist: | activity | name | intro | course | idnumber | | quiz | Quiz 1 | Quiz 1 description | C1 | quiz1 | And quiz "Quiz 1" contains the following questions: | question | page | | TF1 | 1 | When I am on the "Quiz 1" "mod_quiz > View" page logged in as "student" And I press "Attempt quiz" Then I should see "Text of the first question" And I should not see "v1" in the "Question 1" "question" @javascript Scenario: Start a quiz with time limit and password Given the following "activities" exist: | activity | name | intro | course | idnumber | timelimit | quizpassword | | quiz | Quiz 1 | Quiz 1 description | C1 | quiz1 | 3600 | Frog | And quiz "Quiz 1" contains the following questions: | question | page | | TF1 | 1 | When I am on the "Quiz 1" "mod_quiz > View" page logged in as "student" And I press "Attempt quiz" Then I should see "To attempt this quiz you need to know the quiz password" in the "Start attempt" "dialogue" And I should see "Your attempt will have a time limit of 1 hour. When you " in the "Start attempt" "dialogue" And I set the field "Quiz password" to "Frog" And I click on "Start attempt" "button" in the "Start attempt" "dialogue" And I should see "Text of the first question" @javascript Scenario: Cancel starting a quiz with time limit and password Given the following "activities" exist: | activity | name | intro | course | idnumber | timelimit | quizpassword | | quiz | Quiz 1 | Quiz 1 description | C1 | quiz1 | 3600 | Frog | And quiz "Quiz 1" contains the following questions: | question | page | | TF1 | 1 | When I am on the "Quiz 1" "mod_quiz > View" page logged in as "student" And I press "Attempt quiz" And I click on "Cancel" "button" in the "Start attempt" "dialogue" Then I should see "Quiz 1 description" And "Attempt quiz" "button" should be visible @javascript Scenario: Start a quiz with time limit and password, get the password wrong first time Given the following "activities" exist: | activity | name | intro | course | idnumber | timelimit | quizpassword | | quiz | Quiz 1 | Quiz 1 description | C1 | quiz1 | 3600 | Frog | And quiz "Quiz 1" contains the following questions: | question | page | | TF1 | 1 | When I am on the "Quiz 1" "mod_quiz > View" page logged in as "student" And I press "Attempt quiz" And I set the field "Quiz password" to "Toad" And I click on "Start attempt" "button" in the "Start attempt" "dialogue" Then I should see "Quiz 1 description" And I should see "To attempt this quiz you need to know the quiz password" And I should see "Your attempt will have a time limit of 1 hour. When you " And I should see "The password entered was incorrect" And I set the field "Quiz password" to "Frog" And I press "Start attempt" And I should see "Text of the first question" @javascript Scenario: Start a quiz with time limit and password, get the password wrong first time then cancel Given the following "activities" exist: | activity | name | intro | course | idnumber | timelimit | quizpassword | | quiz | Quiz 1 | Quiz 1 description | C1 | quiz1 | 3600 | Frog | And quiz "Quiz 1" contains the following questions: | question | page | | TF1 | 1 | When I am on the "Quiz 1" "mod_quiz > View" page logged in as "student" And I press "Attempt quiz" And I set the field "Quiz password" to "Toad" And I click on "Start attempt" "button" in the "Start attempt" "dialogue" And I should see "Quiz 1 description" And I should see "To attempt this quiz you need to know the quiz password" And I should see "Your attempt will have a time limit of 1 hour. When you " And I should see "The password entered was incorrect" And I set the field "Quiz password" to "Frog" And I press "Cancel" Then I should see "Quiz 1 description" And "Attempt quiz" "button" should be visible @javascript Scenario: Quiz attempt page reloads upon navigating back using the browser's back button Given the following "activities" exist: | activity | name | intro | course | idnumber | timelimit | | quiz | Quiz 1 | Quiz 1 description | C1 | quiz1 | 3600 | And quiz "Quiz 1" contains the following questions: | question | page | | TF1 | 1 | When I am on the "Quiz 1" "mod_quiz > View" page logged in as "student" And I press "Attempt quiz" And I should see "Your attempt will have a time limit of 1 hour. When you " in the "Start attempt" "dialogue" And I start watching to see if a new page loads And I click on "Start attempt" "button" in the "Start attempt" "dialogue" And I press the "back" button in the browser Then a new page should have loaded since I started watching And I should see "Continue your attempt" @javascript Scenario: Start a quiz with pre-created attempts Given the following config values are set as admin: | precreateperiod | 1 | quiz | Given the following "activities" exist: | activity | name | intro | course | idnumber | timeopen | timelimit | quizpassword | attempts | | quiz | Quiz 1 | Quiz 1 description | C1 | quiz1 | ## now ## | 3600 | Frog | 1 | And quiz "Quiz 1" contains the following questions: | question | page | | TF1 | 1 | And quiz "Quiz 1" has pre-created attempts When I am on the "Quiz 1" "mod_quiz > View" page logged in as "student" And I press "Attempt quiz" And I set the field "Quiz password" to "Frog" And I press "Start attempt" Then I should see "Text of the first question" PK9B\R1B"tests/behat/flag_questions.featurenu[@mod @mod_quiz Feature: Flag quiz questions As a student In order to flag a quiz questions All review options for immediately after the attempt are ticked Background: Given the following "users" exist: | username | firstname | lastname | email | | student1 | Student | 1 | student1@email.com | | teacher1 | Teacher | 1 | teacher1@email.com | And the following "courses" exist: | fullname | shortname | | Course 1 | C1 | And the following "course enrolments" exist: | user | course | role | | student1 | C1 | student | | teacher1 | C1 | teacher | And the following "activity" exists: | activity | quiz | | name | Quiz 1 | | course | C1 | | attemptimmediately | 1 | | correctnessimmediately | 1 | | maxmarksimmediately | 1 | | marksimmediately | 1 | | specificfeedbackimmediately | 1 | | generalfeedbackimmediately | 1 | | rightanswerimmediately | 1 | | overallfeedbackimmediately | 1 | | idnumber | 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 | TF1 | First question | | Test questions | truefalse | TF2 | Second question | | Test questions | truefalse | TF3 | Third question | And quiz "Quiz 1" contains the following questions: | question | page | | TF1 | 1 | | TF2 | 2 | | TF3 | 3 | @javascript Scenario: Flag a quiz during and after quiz attempt Given I am on the "Quiz 1" "quiz activity" page logged in as student1 And I press "Attempt quiz" When I click on "Flag question" "button" in the "First question" "question" Then I should see "Remove flag" in the "First question" "question" # Confirm question 1 is flagged in navigation And "Question 1 This page Flagged" "link" should exist # Answer questions And I click on "True" "radio" in the "First question" "question" And I press "Next page" And I click on "True" "radio" in the "Second question" "question" And I press "Next page" And I click on "True" "radio" in the "Third question" "question" And I follow "Finish attempt ..." And I press "Submit all and finish" And I click on "Submit all and finish" "button" in the "Submit all your answers and finish?" "dialogue" # Confirm only flagged question is flagged And I should see "Remove flag" in the "First question" "question" And I should see "Flag question" in the "Second question" "question" And I click on "Flagged" "button" in the "Second question" "question" And I should see "Remove flag" in the "Second question" "question" And I should see "Flag question" in the "Third question" "question" And I am on the "Quiz 1" "mod_quiz > Grades report" page logged in as teacher1 And "Flagged" "icon" should exist in the "Student 1" "table_row" And I am on the "Quiz 1" "mod_quiz > Responses report" page And "Flagged" "icon" should exist in the "Student 1" "table_row" And I am on the "Quiz 1 > student1 > Attempt 1" "mod_quiz > Attempt review" page And I should see "Remove flag" in the "First question" "question" And I should see "Remove flag" in the "Second question" "question" And I should see "Flag question" in the "Third question" "question" PK9B\0TT)tests/behat/attempt_secure_layout.featurenu[@mod @mod_quiz Feature: Attempt a quiz in secure layout As a student In order to demonstrate what I know I need to be able to attempt quizzes in secure layout Background: Given the following "users" exist: | username | firstname | lastname | email | | student | Student | One | student@example.com | And the following "courses" exist: | fullname | shortname | category | | Course 1 | C1 | 0 | And the following "course enrolments" exist: | user | course | role | | student | C1 | student | And the following "activities" exist: | activity | name | intro | course | idnumber | grade | navmethod | browsersecurity | showuserpicture | | quiz | Quiz 1 | Quiz 1 description | C1 | quiz1 | 100 | free | securewindow | 2 | 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 | TF1 | First question | | Test questions | truefalse | TF2 | Second question | And quiz "Quiz 1" contains the following questions: | question | page | maxmark | | TF1 | 1 | | | TF2 | 1 | 3.0 | @javascript Scenario: Large user image in the quiz navigation in secure layout Given I am on the "Quiz 1" "mod_quiz > View" page logged in as "student" And I press "Attempt quiz" And I switch to a second window Then "Student One" "link" should not exist in the "Quiz navigation" "block" Scenario: A quiz page on the secure layout shows both the course name and the quiz name Given I am on the "Quiz 1" "mod_quiz > View" page logged in as "student" And I should see "Quiz 1" And I should see "Quiz 1 description" When I press "Attempt quiz" Then I should see "Course 1" And I should see "Quiz 1" But I should not see "Quiz 1 description" PK9B\ύ.,tests/behat/settings_form_validation.featurenu[@mod @mod_quiz Feature: Settings form fields are validated To help me avoid mistakes As a teacher I need the quiz settings to be validated Background: Given the following "users" exist: | username | firstname | | teacher | Teach | 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 "activities" exist: | activity | course | section | name | | quiz | C1 | 1 | Test quiz 1 | Scenario: Negative time limits are not allowed When I am on the "Test quiz 1" "quiz activity editing" page logged in as teacher And I expand all fieldsets And I set the following fields to these values: | id_timelimit_enabled | 1 | | id_timelimit_number | -10 | And I press "Save and display" Then I should see "This duration cannot be negative" PK9B\i(]ii*tests/behat/quiz_question_versions.featurenu[@mod @mod_quiz Feature: Quiz question versioning In order to manage question versions As a teacher I need to be able to choose which versions can be displayed in a quiz Background: Given the following "courses" exist: | fullname | shortname | category | groupmode | | Course 1 | C1 | 0 | 1 | And the following "users" exist: | username | firstname | lastname | email | | teacher | Teacher | 1 | teacher@example.com | And the following "course enrolments" exist: | user | course | role | | teacher | 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 | | Test questions | truefalse | First question | Answer the first question | | Test questions | truefalse | Other question | Answer the first question | And quiz "Quiz 1" contains the following questions: | question | page | | First question | 1 | @javascript Scenario: Appropriate question version should be displayed when not edited When I am on the "Quiz 1" "mod_quiz > Edit" page logged in as "teacher" Then I should see "First question" And I should see "Answer the first question" And the field "version" matches value "Always latest" And the "version" select box should contain "v1 (latest)" # We check that the corresponding version is the appropriate one in preview And I click on "Preview question" "link" And I switch to "questionpreview" window And I should see "Version 1 (latest)" And I should see "Answer the first question" And I click on "Submit and finish" "button" And I should see "You should have selected true." @javascript Scenario: Approriate question version should be displayed when edited When I am on the "Quiz 1" "mod_quiz > Edit" page logged in as "teacher" And I click on "Edit question First question" "link" # We edit the question with new informations to generate a second version And I set the following fields to these values: | Question name | First question (v2) | | Question text | Answer the new first question | | Correct answer | False | And I press "id_submitbutton" And the field "version" matches value "Always latest" And the "version" select box should contain "v1" And I set the field "version" to "v2 (latest)" Then I should see "First question (v2)" And I should see "Answer the new first question" And I click on "Preview question" "link" And I switch to "questionpreview" window # We check that the corresponding version is the appropriate one in preview # We also check that the new information is properly displayed And I should see "Version 2 (latest)" And I should see "Answer the new first question" @javascript Scenario: Appropriate question version displayed when later draft version exists # Edit the question in the question bank to add a new draft version. Given I am on the "First question" "core_question > edit" page logged in as teacher And I set the following fields to these values: | Question name | First question (v2) | | Question text | Answer the new first question | | Correct answer | False | | Question status | Draft | And I press "id_submitbutton" When I am on the "Quiz 1" "mod_quiz > Edit" page Then I should see "First question" And I should see "Answer the first question" And the field "version" matches value "Always latest" And the "version" select box should contain "v1 (latest)" And the "version" select box should not contain "v2" And the "version" select box should not contain "v2 (latest)" And I am on the "Quiz 1" "mod_quiz > View" page And I press "Preview quiz" And I should see "Answer the first question" @javascript Scenario: Creating a new question should have always latest in the version selection When I am on the "Quiz 1" "mod_quiz > Edit" page logged in as "teacher" # Change the version of the existing question, to ensure it does not match later. And I set the field "version" to "v1 (latest)" And I open the "Page 1" add to quiz menu And I follow "a new question" And I set the field "item_qtype_essay" to "1" And I press "submitbutton" And I set the following fields to these values: | Question name | New essay | | Question text | Write 200 words about quizzes. | And I press "id_submitbutton" And I should see "New essay" on quiz page "1" And the field "version" in the "New essay" "list_item" matches value "Always latest" @javascript Scenario: Adding a question from question bank should have always latest in the version selection When I am on the "Quiz 1" "mod_quiz > Edit" page logged in as "teacher" And I open the "Page 1" add to quiz menu 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 "Other question" "table_row" And I press "Add selected questions to the quiz" Then I should see "Other question" on quiz page "1" And the field "version" in the "Other question" "list_item" matches value "Always latest" @javascript Scenario: Adding a question where all available versions are drafts should display a helpful message. Given 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 teacher And I apply question bank filter "Category" with value "Test questions" And I set the field "question_status_dropdown" in the "First question" "table_row" to "Draft" When I am on the "Quiz 1" "mod_quiz > Edit" page Then I should see "This question is in draft status. To use it in the quiz, go to the question bank and change the status to ready." @javascript Scenario: Previewing a question set to use always latest version will set the preview to always latest version When I am on the "Quiz 1" "mod_quiz > Edit" page logged in as "teacher" And the field "version" in the "First question" "list_item" matches value "Always latest" When I follow "Preview question" And I expand all fieldsets Then the field "Question version" matches value "Always latest" @javascript Scenario: Previewing a question set to use a specific version will set the preview to that version When I am on the "Quiz 1" "mod_quiz > Edit" page logged in as "teacher" And I set the field "version" to "v1 (latest)" When I follow "Preview question" And I expand all fieldsets Then the field "Question version" matches value "1" PK9B\7{{!tests/behat/quiz_feedback.featurenu[@mod @mod_quiz Feature: Enable deferred or immediate feedback for quiz As a teacher I should be able to set how questions behave to deferred or immediate feedback Background: Given the following "users" exist: | username | firstname | lastname | email | | student1 | Student | 1 | student1@example.com | | 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 | | student1 | C1 | student | | 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 | questiontext | | Test questions | truefalse | TF1 | First question | @javascript Scenario: Attempt quiz with How questions behave set to Deferred Feedback Given the following "activity" exists: | activity | quiz | | name | Quiz 1 | | course | C1 | | preferredbehaviour | deferredfeedback | | attemptimmediately | 1 | | correctnessimmediately | 1 | | maxmarksimmediately | 1 | | marksimmediately | 1 | | specificfeedbackimmediately | 1 | | generalfeedbackimmediately | 1 | | rightanswerimmediately | 1 | | overallfeedbackimmediately | 1 | And quiz "Quiz 1" contains the following questions: | question | page | | TF1 | 1 | And I am on the "Quiz 1" "quiz activity" page logged in as student1 When I press "Attempt quiz" # Confirm that check button does not exist when attempting quiz Then "Check Question 1" "button" should not exist And I set the field "False" to "1" And I press "Finish attempt ..." And I should not see "This is the wrong answer." And I should not see "You should have selected true." And I should not see "The correct answer is 'True'." And I press "Submit all and finish" # Confirm that quiz answer feedback only appears when attempt is submitted And I click on "Submit all and finish" "button" in the "Submit all your answers and finish?" "dialogue" 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'." Scenario: Attempt quiz with How questions behave set to Immediate Feedback Given the following "activity" exists: | activity | quiz | | name | Quiz 1 | | course | C1 | | preferredbehaviour | immediatefeedback | | correctnessduring | 1 | | marksduring | 1 | | specificfeedbackduring | 1 | | generalfeedbackduring | 1 | | rightanswerduring | 1 | And quiz "Quiz 1" contains the following questions: | question | page | | TF1 | 1 | And I am on the "Quiz 1" "quiz activity" page logged in as student1 When I press "Attempt quiz" Then "Check Question 1" "button" should exist And I set the field "False" to "1" # Confirm you can check your answer immediately before submitting the attempt And I press "Check" And I should see "The correct answer is 'True'." And the "True" "field" should be disabled And the "False" "field" should be disabled And "Check Question 1" "button" should not exist Scenario Outline: Responses, answers and feedback are displayed after attempting a quiz depending on time finished Given the following "activity" exists: | activity | quiz | | name | Quiz 1 | | course | C1 | | idnumber | quiz1 | | maxmarksimmediately | 0 | | specificfeedbackimmediately | 0 | | rightanswerimmediately | 0 | And quiz "Quiz 1" contains the following questions: | question | page | | TF1 | 1 | And user "student1" has attempted "Quiz 1" with responses submitting "": | slot | response | | 1 | True | When I am on the "Quiz 1" "quiz activity" page logged in as student1 And I click on "Review" "link" Then I should see "You should have selected true." And I see "This is the right answer." And I see "The correct answer is 'True'." And the following exist in the "quizreviewsummary" table: | -1- | -2- | | Marks | 1.00/1.00 | | Grade | 100.00 out of 100.00 | Examples: | timefinish | visibility | | ## 1 minute ago ## | should not | | ## 2 minute ago ## | should | Scenario Outline: Responses, answers and feedback cannot be reviewed after attempting a quiz as long as quiz is open Given the following "activity" exists: | activity | quiz | | name | Quiz 1 | | course | C1 | | idnumber | quiz1 | | timeclose | | | generalfeedbackimmediately | 0 | | attemptimmediately | 0 | | overallfeedbackopen | 0 | | rightansweropen | 0 | | generalfeedbackopen | 0 | | specificfeedbackopen | 0 | | marksopen | 0 | | maxmarksopen | 0 | | correctnessopen | 0 | | attemptopen | 0 | And quiz "Quiz 1" contains the following questions: | question | page | | TF1 | 1 | And user "student1" has attempted "Quiz 1" with responses: | slot | response | | 1 | True | When I am on the "Quiz 1" "quiz activity" page logged in as student1 # Confirm the Review link visibility. Should exist if quiz is closed, otherwise, it shouldn't exist. Then "Review" "link" exist # Confirm the `Available` text and close date visibility. Should only be visible while quiz is open. And I see "Available" And I see "##tomorrow##%d/%m/%y##" Examples: | quizclosedate | reviewlinkvisibility | availabilityvisibility | | ## tomorrow ## | should not | should | | ## yesterday ## | should | should not | PK9B\7>#tests/behat/attempt_invalid.featurenu[@mod_quiz Feature: A quiz with invalid question types should not be able to be attempted As a teacher If my quiz has questions with invalid types I want my students to be unable to attempt the quiz until it is fixed Background: Given the following "users" exist: | username | firstname | lastname | email | | student | Student | One | student@example.com | And the following "courses" exist: | fullname | shortname | category | | Course 1 | C1 | 0 | And the following "course enrolments" exist: | user | course | role | | student | C1 | student | 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 | admin | A text | | Test questions | essay | Question 2 | admin | B text | And the following "activities" exist: | activity | name | intro | course | idnumber | grade | navmethod | | quiz | Quiz 1 | Quiz 1 description | C1 | quiz1 | 100 | free | And quiz "Quiz 1" contains the following questions: | question | page | maxmark | | Question 1 | 1 | | | Question 2 | 1 | | And question "Question 2" is changed to simulate being of an uninstalled type @javascript Scenario: Quiz with invalid questions should disable attempts Given I am logged in as "student" When I am on the "Quiz 1" "mod_quiz > View" page Then I should see "This quiz has questions with invalid types" And I should not see "Attempt quiz" PK9B\+6161*tests/behat/attempt_redo_questions.featurenu[@mod @mod_quiz Feature: Allow students to redo questions in a practice quiz, without starting a whole new attempt In order to practice particular skills I am struggling with As a student I need to be able to redo each question in a quiz as often as necessary without starting a whole new attempt, if my teacher allows it. Background: Given the following "users" exist: | username | firstname | lastname | | student | Student | One | | teacher | Teacher | One | | editor | Question | Editor | And the following "courses" exist: | fullname | shortname | category | | Course 1 | C1 | 0 | And the following "course enrolments" exist: | user | course | role | | student | C1 | student | | teacher | C1 | teacher | | editor | C1 | editingteacher | And the following "activities" exist: | activity | name | intro | course | idnumber | preferredbehaviour | canredoquestions | | quiz | Quiz 1 | Quiz 1 description | C1 | quiz1 | immediatefeedback | 1 | | 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 | questiontext | | Test questions | truefalse | TF1 | First question | | Test questions | truefalse | TF2 | Second question | And quiz "Quiz 1" contains the following questions: | question | page | maxmark | | TF1 | 1 | 2 | | TF2 | 1 | 1 | @javascript Scenario: After completing a question, there is a redo question button that restarts the question Given I am on the "Quiz 1" "mod_quiz > View" page logged in as "student" When I press "Attempt quiz" And I click on "False" "radio" in the "First question" "question" And I click on "Check" "button" in the "First question" "question" And I press "Try another question like this one" Then the state of "First question" question is shown as "Not complete" And I should see "Marked out of 2.00" in the "First question" "question" @javascript Scenario: After redoing a question, regrade works Given I am on the "Quiz 1" "mod_quiz > View" page logged in as "student" When I press "Attempt quiz" And I click on "False" "radio" in the "First question" "question" And I click on "Check" "button" in the "First question" "question" And I press "Try another question like this one" And I am on the "Quiz 1" "mod_quiz > Grades report" page logged in as "teacher" And I press "Regrade attempts..." And I click on "Regrade now" "button" in the "Regrade" "dialogue" Then I should see "Finished regrading (1/1)" And I should see "Regrade completed" And I press "Continue" # Regrade a second time, to ensure the first regrade did not corrupt any data. And I press "Regrade attempts..." And I click on "Regrade now" "button" in the "Regrade" "dialogue" And I should see "Finished regrading (1/1)" And I should see "Regrade completed" @javascript Scenario: Start attempt, teacher edits question, redo picks up latest non-draft version # Start attempt as student. Given I am on the "Quiz 1" "mod_quiz > View" page logged in as "student" And I press "Attempt quiz" And I click on "False" "radio" in the "First question" "question" And I click on "Check" "button" in the "First question" "question" And I log out # Now edit the question as teacher to add a real version and a draft version. # Would be nice to do this with a generator, but I don't have time right now. And I am on the "TF1" "core_question > edit" page logged in as "editor" And I set the following fields to these values: | Question name | TF1-v2 | | Question text | The new first question | | Correct answer | False | And I press "id_submitbutton" And I am on the "TF1-v2" "core_question > edit" page And I set the following fields to these values: | Question name | TF1-v3 | | Question text | This is only draft for now | | Correct answer | True | | Question status | Draft | And I press "id_submitbutton" And I log out When I am on the "Quiz 1" "mod_quiz > View" page logged in as "student" And I press "Continue your attempt" And I press "Try another question like this one" Then the state of "The new first question" question is shown as "Not complete" And I should see "Marked out of 2.00" in the "The new first question" "question" And I should not see "This is only draft for now" @javascript Scenario: The redo question button is visible but disabled for teachers Given I am on the "Quiz 1" "mod_quiz > View" page logged in as "student" When I press "Attempt quiz" And I click on "False" "radio" in the "First question" "question" And I click on "Check" "button" in the "First question" "question" And I log out And I am on the "Quiz 1" "mod_quiz > View" page logged in as "teacher" And I follow "Attempts: 1" And I follow "Review attempt" Then the "Try another question like this one" "button" should be disabled @javascript Scenario: The redo question buttons are no longer visible after the attempt is submitted. Given I am on the "Quiz 1" "mod_quiz > View" page logged in as "student" When I press "Attempt quiz" And I click on "False" "radio" in the "First question" "question" And I click on "Check" "button" in the "First question" "question" And I press "Finish attempt ..." And I press "Submit all and finish" And I click on "Submit all and finish" "button" in the "Submit all your answers and finish?" "dialogue" Then "Try another question like this one" "button" should not exist @javascript @_switch_window Scenario: Teachers reviewing can see all the questions attempted in a slot Given I am on the "Quiz 1" "mod_quiz > View" page logged in as "student" And I press "Attempt quiz" And I click on "False" "radio" in the "First question" "question" And I click on "Check" "button" in the "First question" "question" And I press "Try another question like this one" And I press "Finish attempt ..." And I press "Submit all and finish" And I click on "Submit all and finish" "button" in the "Submit all your answers and finish?" "dialogue" And I log out When I am on the "Quiz 1" "mod_quiz > View" page logged in as "teacher" And I follow "Attempts: 1" And I follow "Review attempt" And I click on "1" "link" in the "First question" "question" And I switch to "reviewquestion" window Then the state of "First question" question is shown as "Incorrect" And I click on "1" "link" in the "First question" "question" And the state of "First question" question is shown as "Not complete" And I switch to the main window And the state of "First question" question is shown as "Not answered" And I should not see "Submit" in the ".history" "css_element" And I am on the "Quiz 1" "mod_quiz > Statistics report" page logged in as teacher And I follow "TF1" And "False" row "Frequency" column of "quizresponseanalysis" table should contain "100.00%" And "True" row "Frequency" column of "quizresponseanalysis" table should contain "0.00%" And "[No response]" row "Frequency" column of "quizresponseanalysis" table should contain "100.00%" @javascript @_switch_window Scenario: Teachers reviewing can switch between attempts in the review question popup Given I am on the "Quiz 1" "mod_quiz > View" page logged in as student # Create two attempts, only one of which has a redo. When I press "Attempt quiz" And I click on "False" "radio" in the "First question" "question" And I click on "Check" "button" in the "First question" "question" And I press "Try another question like this one" And I press "Finish attempt ..." And I press "Submit all and finish" And I click on "Submit all and finish" "button" in the "Submit all your answers and finish?" "dialogue" And I follow "Finish review" And I press "Re-attempt quiz" And I click on "True" "radio" in the "First question" "question" And I click on "Check" "button" in the "First question" "question" And I log out And I am on the "Quiz 1" "mod_quiz > View" page logged in as teacher And I follow "Attempts: 2" # Review the first attempt - and switch to the first question seen. And I follow "Review attempt" And I click on "1" "link" in the "First question" "question" And I switch to "reviewquestion" window And "Summary of attempt" "table" should exist And the state of "First question" question is shown as "Incorrect" # Now switch to the other quiz attempt using the link at the top, which does not have a redo. And I click on "2" "link" in the "Attempts" "table_row" Then the state of "First question" question is shown as "Correct" And I should not see "Other questions attempted here" @javascript Scenario: Redoing question 1 should save any changes to question 2 on the same page Given I am on the "Quiz 1" "mod_quiz > View" page logged in as "student" When I press "Attempt quiz" And I click on "False" "radio" in the "First question" "question" And I click on "Check" "button" in the "First question" "question" And I click on "True" "radio" in the "Second question" "question" And I press "Try another question like this one" And I click on "Check" "button" in the "Second question" "question" Then the state of "Second question" question is shown as "Correct" @javascript Scenario: Redoing questions should work with random questions as well Given the following "activities" exist: | activity | name | intro | course | idnumber | preferredbehaviour | canredoquestions | | quiz | Quiz 2 | Quiz 2 description | C1 | quiz2 | immediatefeedback | 1 | And I am on the "Quiz 2" "mod_quiz > Edit" page logged in as "admin" And I open the "last" add to quiz menu And I follow "a random question" 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 "Test questions" And I press "Add random question" And user "student" has started an attempt at quiz "Quiz 2" randomised as follows: | slot | actualquestion | | 1 | TF1 | And I am on the "Quiz 2" "mod_quiz > View" page logged in as "student" When I press "Continue your attempt" And I should see "First question" And I click on "False" "radio" And I click on "Check" "button" And I press "Try another question like this one" And I should see "Second question" And "Check" "button" should exist Scenario: Teachers reviewing can see author of action in review attempt Given the following "questions" exist: | questioncategory | qtype | name | questiontext | answer 1 | grade | | Test questions | shortanswer | SA1 | Who is author of Harry Potter? | J.K.Rowling | 100% | And the following "activities" exist: | activity | name | intro | course | idnumber | | quiz | Quiz 2 | Quiz 2 description | C1 | quiz2 | And quiz "Quiz 2" contains the following questions: | question | page | | SA1 | 1 | And user "student" has attempted "Quiz 2" with responses: | slot | response | | 1 | J.K.Rowling | And I am on the "Quiz 2" "mod_quiz > Manual grading report" page logged in as "teacher" And I follow "Also show questions that have been graded automatically" When I click on "update grades" "link" in the "SA1" "table_row" Then I set the field "Comment" to "I have adjusted your mark to 1.0" And I set the field "Mark" to "1.0" And I press "Save and show next" And I follow "Results" And I follow "Review attempt" And I should see "Teacher One" in the "I have adjusted your mark to 1.0" "table_row" PK9B\[Vd4 4 tests/behat/backup.featurenu[@mod @mod_quiz Feature: Backup and restore of quizzes In order to reuse my quizzes As a teacher I need to be able to back them up and restore them. Background: Given the following "courses" exist: | fullname | shortname | category | | Course 1 | C1 | 0 | And the following "activities" exist: | activity | name | intro | course | idnumber | | quiz | Quiz 1 | For testing backup | C1 | quiz1 | And the following "question categories" exist: | contextlevel | reference | name | | Activity module | quiz1 | Test questions | And the following config values are set as admin: | enableasyncbackup | 0 | And I log in as "admin" @javascript Scenario: Duplicate a quiz with two questions And the following "questions" exist: | questioncategory | qtype | name | questiontext | | Test questions | truefalse | TF1 | First question | | Test questions | truefalse | TF2 | Second question | And quiz "Quiz 1" contains the following questions: | question | page | | TF1 | 1 | | TF2 | 2 | When I am on "Course 1" course homepage with editing mode on And I duplicate "Quiz 1" activity editing the new copy with: | Name | Quiz 2 | And I am on the "Quiz 1" "mod_quiz > Edit" page Then I should see "TF1" And I should see "TF2" @javascript Scenario: Backup and restore a course containing a quiz with user data. And the following "questions" exist: | questioncategory | qtype | name | questiontext | | Test questions | truefalse | TF1 | First question | | Test questions | truefalse | TF2 | Second question | And quiz "Quiz 1" contains the following questions: | question | page | | TF1 | 1 | | TF2 | 2 | And the following "users" exist: | username | | student | And the following "course enrolments" exist: | user | course | role | | student | C1 | student | And user "student" has attempted "Quiz 1" with responses: | slot | response | | 1 | True | | 2 | False | 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 | Restored course | Then I should see "Restored course" And I click on "Quiz 1" "link" in the "region-main" "region" And I should see "Attempts: 1" @javascript @_file_upload Scenario: Restore a Moodle 2.8 quiz backup When I am on the "Course 1" "restore" page And I press "Manage course backups" And I upload "mod/quiz/tests/fixtures/moodle_28_quiz.mbz" file to "Files" filemanager And I press "Save changes" And I restore "moodle_28_quiz.mbz" backup into "Course 1" course using this options: And I am on the "Restored Moodle 2.8 quiz" "mod_quiz > Edit" page Then I should see "TF1" And I should see "TF2" PK9B\3n+tests/behat/editing_remove_question.featurenu[@mod @mod_quiz Feature: Edit quiz page - remove questions In order to change the layout of a quiz I built As a teacher I need to be able to delete questions. 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 | Quiz 1 | C1 | quiz1 | And the following "question categories" exist: | contextlevel | reference | name | | Activity module | quiz1 | Test questions | And I log in as "teacher1" @javascript Scenario: Delete questions by clicking on the delete icon. Given the following "questions" exist: | questioncategory | qtype | name | questiontext | | Test questions | truefalse | Question A | This is question 01 | | Test questions | truefalse | Question B | This is question 02 | | Test questions | truefalse | Question C | This is question 03 | And quiz "Quiz 1" contains the following questions: | question | page | | Question A | 1 | | Question B | 1 | | Question C | 2 | And I am on the "Quiz 1" "mod_quiz > Edit" page # Confirm the starting point. Then I should see "Question A" on quiz page "1" And I should see "Question B" on quiz page "1" And I should see "Question C" on quiz page "2" And I should see "Total of marks: 3.00" And I should see "Questions: 3" And I should see "This quiz is open" # Delete last question in last page. Page contains multiple questions When I delete "Question C" in the quiz by clicking the delete icon Then I should see "Question A" on quiz page "1" And I should see "Question B" on quiz page "1" And I should not see "Question C" on quiz page "2" And I should see "Total of marks: 2.00" And I should see "Questions: 2" # Delete last question in last page. The page contains multiple questions and there are multiple pages. When I click on the "Add" page break icon after question "Question A" Then I should see "Question B" on quiz page "2" And the "Remove" page break icon after question "Question A" should exist And I delete "Question A" in the quiz by clicking the delete icon Then I should see "Question B" on quiz page "1" And I should not see "Page 2" And I should not see "Question A" on quiz page "2" And the "Remove" page break icon after question "Question B" should not exist And I should see "Total of marks: 1.00" @javascript Scenario: Cannot delete the last question in a section. Given the following "questions" exist: | questioncategory | qtype | name | questiontext | | Test questions | truefalse | Question A | This is question 01 | | Test questions | truefalse | Question B | This is question 02 | | Test questions | truefalse | Question C | This is question 03 | And quiz "Quiz 1" contains the following questions: | question | page | | Question A | 1 | | Question B | 1 | | Question C | 2 | And quiz "Quiz 1" contains the following sections: | heading | firstslot | shuffle | | Heading 1 | 1 | 1 | | Heading 2 | 2 | 1 | And I am on the "Quiz 1" "mod_quiz > Edit" page Then "Delete" "link" in the "Question A" "list_item" should not be visible Then "Delete" "link" in the "Question B" "list_item" should be visible Then "Delete" "link" in the "Question C" "list_item" should be visible @javascript Scenario: Can delete the last question in a quiz. Given the following "questions" exist: | questioncategory | qtype | name | questiontext | | Test questions | truefalse | Question A | This is question 01 | And quiz "Quiz 1" contains the following questions: | question | page | | Question A | 1 | And I am on the "Quiz 1" "mod_quiz > Edit" page When I delete "Question A" in the quiz by clicking the delete icon Then I should see "Questions: 0" PK9B\h.tests/behat/editing_question_numbering.featurenu[@mod @mod_quiz @javascript Feature: Editing question numbering of the existing questions already in a quiz In order to have better assessment As a teacher I want to be able to customided question numbering on the quiz editing page Background: Given the following "users" exist: | username | firstname | lastname | email | | teacher1 | Teacher | 1 | t1@example.com | And the following "courses" exist: | fullname | shortname | | Course 1 | C1 | And the following "course enrolments" exist: | user | course | role | | teacher1 | C1 | editingteacher | And the following "activities" exist: | activity | name | intro | course | idnumber | | quiz | Quiz 1 | Quiz 1 for testing | C1 | quiz1 | | quiz | Quiz 2 | Quiz 2 for testing | C1 | quiz2 | | qbank | Qbank 1 | Qbank for testing | C1 | qbank1 | And the following "question categories" exist: | contextlevel | reference | name | | Activity module | qbank1 | Questions Category 1| | Activity module | qbank1 | Questions Category 2| Given the following "questions" exist: | questioncategory | qtype | name | questiontext | | Questions Category 1 | description | Description | This is decription ... | | Questions Category 1 | truefalse | Question A | This is question 01 | | Questions Category 1 | truefalse | Question B | This is question 02 | | Questions Category 1 | truefalse | Question C | This is question 03 | | Questions Category 1 | truefalse | Question D | This is question 04 | And quiz "Quiz 1" contains the following questions: | question | page | displaynumber | | Description | 1 | | | Question A | 1 | 1.a | | Question B | 1 | 1.b | | Question C | 2 | | | Question D | 2 | | And quiz "Quiz 1" contains the following sections: | heading | firstslot | shuffle | | Section 1 | 1 | 0 | | Section 2 | 4 | 0 | Scenario: Showing customised and default question numbers on quiz editing page. Given I am on the "Quiz 1" "mod_quiz > Edit" page logged in as "teacher1" And I should see "Section 1" And I should see "i" on quiz page "1" And I should see "1.a" on quiz page "1" And I should see "1.b" on quiz page "1" And I should see "Section 2" And I should see "3" on quiz page "2" And I should see "4" on quiz page "2" Scenario: Showing customised and default question numbers on quiz view page and question navigation. Given I am on the "Quiz 1" "mod_quiz > View" page logged in as "teacher1" When I press "Preview quiz" Then I should see "Section 1" in the "Quiz navigation" "block" And I should see question "1.a" in section "Section 1" in the quiz navigation And I should see question "1.b" in section "Section 1" in the quiz navigation And I should see "Section 2" in the "Quiz navigation" "block" And I should see question "3" in section "Section 2" in the quiz navigation And I should see question "4" in section "Section 2" in the quiz navigation And I should see "Question 1.a" And I should see "Question 1.b" And I press "Next page" And I should see "Question 3" And I should see "Question 4" Scenario: Customised numbers are not used in shuffled sections, even if they exist in the database Given I am on the "Quiz 1" "mod_quiz > Edit" page logged in as "teacher1" And I set the field "Shuffle" to "1" When I am on the "Quiz 1" "mod_quiz > View" page And I press "Preview quiz" Then I should see "Section 1" in the "Quiz navigation" "block" And I should see question "1" in section "Section 1" in the quiz navigation And I should see question "2" in section "Section 1" in the quiz navigation And I should see "Section 2" in the "Quiz navigation" "block" And I should see question "3" in section "Section 2" in the quiz navigation And I should see question "4" in section "Section 2" in the quiz navigation And I should see "Question 1" And I should see "Question 2" And I press "Next page" And I should see "Question 3" And I should see "Question 4" Scenario: Showing long customised question numbers on quiz editing page and parcially hidden on question info and navigation. Given quiz "Quiz 2" contains the following questions: | question | page | displaynumber | | Question A | 1 | ABCDEFGHIJKLMNOP | | Question B | 2 | abcdefghijklmnop | And quiz "Quiz 2" contains the following sections: | heading | firstslot | shuffle | | Section 1 | 1 | 0 | | Section 2 | 2 | 0 | When I am on the "Quiz 2" "mod_quiz > Edit" page logged in as "teacher1" And I should see "ABCDEFGHIJKLMNOP" on quiz page "1" And I should see "abcdefghijklmnop" on quiz page "2" And I am on the "Quiz 2" "mod_quiz > View" page logged in as "teacher1" And I press "Preview quiz" # Only "Question ABCDEFGH" is visible in the question info box. And I should see "Question ABCDEFGHIJKLMNOP" # Only 'ABC' is visible on the navigation button/link. And I should see question "ABCDEFGHIJKLMNOP" in section "Section 1" in the quiz navigation And I press "Next page" # Only "Question abcdefghij" is visible in the question info box. And I should see "Question abcdefghijklmnop" # Only 'abc' is visible on the navigation button/link. And I should see question "abcdefghijklmnop" in section "Section 2" in the quiz navigation Scenario: Shuffling questions within a section with customised question numbers. When I am on the "Quiz 1" "mod_quiz > Edit" page logged in as "teacher1" Then I should see "Section 1" And I should see "i" on quiz page "1" And I should see "1.a" on quiz page "1" And I should see "1.b" on quiz page "1" And I should see "Section 2" And I should see "3" on quiz page "2" And I should see "4" on quiz page "2" And I set the field "Shuffle" to "1" And I should see "Section 1" And I should see "i" on quiz page "1" And I should see "1" on quiz page "1" And I should see "2" on quiz page "1" And I should see "Section 2" And I should see "3" on quiz page "2" And I should see "4" on quiz page "2" And I reload the page And I set the field "Shuffle" to "0" And I should see "Section 1" And I should see "i" on quiz page "1" And I should see "1.a" on quiz page "1" And I should see "1.b" on quiz page "1" PK9B\TIf#tests/behat/random_question.featurenu[@mod @mod_quiz Feature: Moving a question to another category should not affect random questions in a quiz In order for a quiz with random questions to work as expected Teachers should be able to Move a question to a different category without affecting the category the random questions in the quiz reference to 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 | | quiz | Quiz 1 | Quiz 1 for testing the Add random question form | C1 | quiz1 | | qbank | Qbank 1 | Question bank 1 | C1 | qbank1 | And the following "question categories" exist: | contextlevel | reference | questioncategory | name | | Activity module | qbank1 | top | Default for Qbank 1 | | Activity module | qbank1 | Default for Qbank 1 | Subcategory | | Activity module | qbank1 | top | Used category | And the following "questions" exist: | questioncategory | qtype | name | questiontext | | Used category | essay | Test question to be moved | Write about whatever you want | @javascript Scenario: Moving a question should not change the random question Given I am on the "Quiz 1" "mod_quiz > Edit" page logged in as "teacher1" When I open the "last" add to quiz menu And I follow "a random question" 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 "Used category" And I press "Add random question" And I should see "Random (Used category) based on filter condition" on quiz page "1" And I click on "Configure question" "link" in the "Random (Used category) based on filter condition" "list_item" And I should see "Used category" And I am on "Course 1" course homepage And I navigate to "Question banks" in current page administration And I click on "Qbank 1" "link" And I apply question bank filter "Category" with value "Used category" And I click on "Test question to be moved" "checkbox" in the "Test question to be moved" "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 "Subcategory" "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 Then I should see "Subcategory (1)" And I am on the "Quiz 1" "mod_quiz > Edit" page And I should see "Random (Used category) based on filter condition" on quiz page "1" And I click on "Configure question" "link" in the "Random (Used category) based on filter condition" "list_item" And I should see "Used category" @javascript Scenario: Renaming a random question category should update the random question Given I am on the "Quiz 1" "mod_quiz > Edit" page logged in as "teacher1" When I open the "last" add to quiz menu And I follow "a random question" Then 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 "Used category" And I press "Add random question" And I should see "Random (Used category) based on filter condition" on quiz page "1" And 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 "Edit" in the open action menu And I set the following fields to these values: | Name | Used category new | | Category info | I was edited | And I press "Save changes" And I set the field "Show descriptions" to "1" Then I should see "Used category new" And I should see "I was edited" in the "Used category new" "list_item" And I am on the "Quiz 1" "mod_quiz > Edit" page And I should see "Random (Used category new) based on filter condition" on quiz page "1" @javascript Scenario: A random question with an invalid category should still be editable Given I am on the "Quiz 1" "mod_quiz > Edit" page logged in as "teacher1" When I open the "last" add to quiz menu And I follow "a random question" Then 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 "Used category" And I press "Add random question" And I should see "Random (Used category) based on filter condition" on quiz page "1" And 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 press "Save in category" And I am on the "Quiz 1" "mod_quiz > Edit" page logged in as "teacher1" Then I should not see "Random (Used category) based on filter condition" on quiz page "1" And I should see "Missing question category" on quiz page "1" PK9B\|C,tests/behat/quiz_activity_completion.featurenu[@mod @mod_quiz @core_completion Feature: View activity completion in the quiz activity In order to have visibility of quiz completion requirements As a student I need to be able to view my quiz completion progress Background: Given the following "users" exist: | username | firstname | lastname | email | | student1 | Student | 1 | student1@example.com | | teacher1 | Teacher | 1 | teacher1@example.com | And the following "courses" exist: | fullname | shortname | category | enablecompletion | | Course 1 | C1 | 0 | 1 | And the following "course enrolments" exist: | user | course | role | | teacher1 | C1 | editingteacher | | student1 | C1 | student | And the following "activity" exists: | activity | quiz | | course | C1 | | idnumber | quiz1 | | name | Test quiz name | | section | 1 | | attempts | 2 | | gradepass | 5.00 | | completion | 2 | | completionview | 1 | | completionusegrade | 1 | | completionpassgrade | 1 | | completionattemptsexhausted | 1 | | completionminattemptsenabled | 1 | | completionminattempts | 1 | 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 | And quiz "Test quiz name" contains the following questions: | question | page | | First question | 1 | Scenario Outline: A student can complete a quiz activity by achieving a passing grade When I log in as "student1" And I am on "Course 1" course homepage And I follow "Test quiz name" And the "View" completion condition of "Test quiz name" is displayed as "done" And the "Make attempts: 1" completion condition of "Test quiz name" is displayed as "todo" And the "Receive a grade" completion condition of "Test quiz name" is displayed as "todo" And the "Receive a passing grade" completion condition of "Test quiz name" is displayed as "todo" And the "Receive a pass grade or complete all available attempts" completion condition of "Test quiz name" is displayed as "todo" And I press "Attempt quiz" And I set the field "False" to "1" And I press "Finish attempt ..." And I press "Submit all and finish" And I am on "Course 1" course homepage And I follow "Test quiz name" And the "View" completion condition of "Test quiz name" is displayed as "done" And the "Make attempts: 1" completion condition of "Test quiz name" is displayed as "done" And the "Receive a grade" completion condition of "Test quiz name" is displayed as "done" And the "Receive a passing grade" completion condition of "Test quiz name" is displayed as "failed" And the "Receive a pass grade or complete all available attempts" completion condition of "Test quiz name" is displayed as "todo" And I run all adhoc tasks And I reload the page And the "Receive a grade" completion condition of "Test quiz name" is displayed as "done" And the "Receive a passing grade" completion condition of "Test quiz name" is displayed as "failed" And I press "Re-attempt quiz" And I set the field "" to "1" And I press "Finish attempt ..." And I press "Submit all and finish" And I follow "Finish review" And the "View" completion condition of "Test quiz name" is displayed as "done" And the "Make attempts: 1" completion condition of "Test quiz name" is displayed as "done" And the "Receive a grade" completion condition of "Test quiz name" is displayed as "done" And the "Receive a passing grade" completion condition of "Test quiz name" is displayed as "" And the "Receive a pass grade or complete all available attempts" completion condition of "Test quiz name" is displayed as "done" And I log out And I log in as "teacher1" And I am on "Course 1" course homepage And I navigate to "Reports" in current page administration And I click on "Activity completion" "link" And "" "icon" should exist in the "Student 1" "table_row" Examples: | answer | passcompletionexpected | expectedactivitycompletion | | False | failed | Completed (did not achieve pass grade) | | True | done | Completed (achieved pass grade) | PK9B\ wtests/behat/group_mode.featurenu[@mod @mod_quiz Feature: Viewing results by group In order to view quiz results on a large course As a teacher I need to filter results by group Background: And the following "courses" exist: | fullname | shortname | | Test Course 1 | C1 | And the following "groups" exist: | name | course | idnumber | participation | | Group 1 | C1 | G1 | 1 | | Group 2 | C1 | G2 | 1 | | Group 3 | C1 | G3 | 0 | And the following "users" exist: | username | firstname | lastname | email | | teacher1 | TeacherG1 | 1 | teacher1@example.com | | noneditor1 | NoneditorG1 | 1 | noneditor1@example.com | | noneditor2 | NoneditorNone | 2 | noneditor2@example.com | | user1 | User1G1 | 1 | user1@example.com | | user2 | User2G2 | 2 | user2@example.com | | user3 | User3None | 3 | user3@example.com | | user4 | User4NPgroup | 4 | user4@example.com | And the following "course enrolments" exist: | user | course | role | | teacher1 | C1 | editingteacher | | noneditor1 | C1 | teacher | | noneditor2 | C1 | teacher | | user1 | C1 | student | | user2 | C1 | student | | user3 | C1 | student | | user4 | C1 | student | And the following "group members" exist: | user | group | | teacher1 | G1 | | noneditor1 | G1 | | user1 | G1 | | user2 | G2 | | user4 | G3 | And the following "activities" exist: | activity | name | intro | course | idnumber | groupmode | | quiz | Separate quiz | quiz with separate groups | C1 | quiz1 | 1 | | quiz | Visible quiz | quiz with visible groups | C1 | quiz2 | 2 | | 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 | questiontext | | Test questions | truefalse | TF1 | First question | | Test questions | truefalse | TF2 | Second question | And quiz "Separate quiz" contains the following questions: | question | page | | TF1 | 1 | And quiz "Visible quiz" contains the following questions: | question | page | | TF1 | 1 | Scenario Outline: Editing teachers should see all groups on the Results page. Non-editing teachers should see just their own groups in Separate groups mode, all groups in Visible groups mode. Given I am on the "" "quiz activity" page logged in as "" And I follow "Results" Then I "All participants" And I "Group 1" And I "Group 2" And I "Sorry, but you need to be part of a group to see this page." And I should not see "Group 3" Examples: | quiz | user | all | G1 | G2 | error | | quiz1 | teacher1 | should see | should see | should see | should not see | | quiz1 | noneditor1 | should not see | should see | should not see | should not see | | quiz1 | noneditor2 | should see | should not see | should not see | should see | | quiz2 | teacher1 | should see | should see | should see | should not see | | quiz2 | noneditor1 | should see | should see | should see | should not see | | quiz2 | noneditor2 | should see | should see | should see | should not see | PK9B\PZ 3tests/behat/quiz_interactive_multiple_tries.featurenu[@mod @mod_quiz Feature: Set a quiz to be interactive with multiple tries In order to attempt an interactive quiz multiple times As a teacher I should be able to set how questions behave to interactive with multiple tries Background: Given the following "users" exist: | username | firstname | lastname | email | | student1 | Student | One | student1@example.com | | teacher1 | Teacher | One | teacher1@example.com | And the following "courses" exist: | fullname | shortname | category | | Course 1 | C1 | 0 | And the following "course enrolments" exist: | user | course | role | | student1 | C1 | student | | teacher1 | C1 | editingteacher | And the following "activities" exist: | activity | name | course | preferredbehaviour | specificfeedbackduring | idnumber | | quiz | Quiz 1 | C1 | interactive | 1 | quiz1 | And the following "question categories" exist: | contextlevel | reference | name | | Activity module | quiz1 | Test questions | And the following "questions" exist: | questioncategory | qtype | name | questiontext | template | hint1 | hint2 | shuffleanswers | | Test questions | multichoice | MC1 | First question | one_of_four | Hint 1 | Hint 2 | 0 | | Test questions | multichoice | MC2 | Second question | one_of_four | Hint 1 | Hint 2 | 0 | And quiz "Quiz 1" contains the following questions: | question | page | | MC1 | 1 | | MC2 | 2 | @javascript Scenario: Attempt an interactive quiz with multiple tries Given I am on the "Quiz 1" "quiz activity" page logged in as student1 And I press "Attempt quiz" # Answer the question incorrectly. And I click on "Two" "qtype_multichoice > Answer" in the "Question 1" "question" When I press "Check" # Confirm that correct feedback is displayed. Then I should see "That is not right at all." And I should see "Hint 1." # Confirm answer cannot be changed after checking your answer. And I should not see "Clear my choice" And "Check" "button" should not be visible # Attempt question again. And I press "Try again" And I should see "Clear my choice" # Answer the question correctly. And I click on "One" "qtype_multichoice > Answer" in the "Question 1" "question" And I press "Check" # Confirm correct feedback is displayed. And I should see "Well done!" And I should see "The correct answer is: One" And I should not see "Hint 1." And "Try again" "button" should not be visible And I press "Next page" # Answer question incorrectly. And I click on "Two" "qtype_multichoice > Answer" in the "Question 2" "question" And I press "Check" # Confirm Hint 1 is displayed. And I should see "Hint 1." And I press "Try again" # Answer question incorrectly again. And I click on "Three" "qtype_multichoice > Answer" in the "Question 2" "question" And I press "Check" # Confirm Hint 2 is displayed. And I should see "Hint 2." And I press "Try again" # Answer question incorrectly again. And I click on "Four" "qtype_multichoice > Answer" in the "Question 2" "question" And I press "Check" # Confirm you can no longer re-attempt the question. And I should not see "Try again" PK9B\MKMK,tests/behat/editing_section_headings.featurenu[@mod @mod_quiz Feature: Edit quiz page - section headings In order to build a quiz laid out in sections the way I want As a teacher I need to be able to add, edit and remove section headings as well as shuffle questions within a section. 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 | | qbank | Qbank 1 | C1 | qbank1 | And the following "question categories" exist: | contextlevel | reference | name | | Activity module | qbank1 | Test questions | And I log in as "teacher1" @javascript Scenario: We have a quiz with one default section Given the following "activities" exist: | activity | name | intro | course | idnumber | | quiz | Quiz 1 | Quiz 1 description | C1 | quiz1 | And the following "questions" exist: | questioncategory | qtype | name | questiontext | | Test questions | truefalse | TF1 | This is question 01 | | Test questions | truefalse | TF2 | This is question 02 | | Test questions | truefalse | TF3 | This is question 03 | And quiz "Quiz 1" contains the following questions: | question | page | | TF1 | 1 | | TF2 | 2 | | TF3 | 3 | When I am on the "Quiz 1" "mod_quiz > Edit" page Then I should see "Shuffle" @javascript Scenario: Modify the default section headings Given the following "activities" exist: | activity | name | intro | course | idnumber | | quiz | Quiz 1 | Quiz 1 description | C1 | quiz1 | When I am on the "Quiz 1" "mod_quiz > Edit" page And I change quiz section heading "" to "This is section one" Then I should see "This is section one" @javascript Scenario: Modify section headings Given the following "activities" exist: | activity | name | intro | course | idnumber | | quiz | Quiz 1 | Quiz 1 description | C1 | quiz1 | And the following "questions" exist: | questioncategory | qtype | name | questiontext | | Test questions | truefalse | TF1 | This is question 01 | | Test questions | truefalse | TF2 | This is question 02 | | Test questions | truefalse | TF3 | This is question 03 | | Test questions | truefalse | TF4 | This is question 04 | | Test questions | truefalse | TF5 | This is question 05 | And quiz "Quiz 1" contains the following questions: | question | page | | TF1 | 1 | | TF2 | 2 | | TF3 | 3 | | TF4 | 3 | And quiz "Quiz 1" contains the following sections: | heading | firstslot | shuffle | | | 1 | 0 | | Heading 2 | 2 | 0 | | Heading 3 | 3 | 1 | When I am on the "Quiz 1" "mod_quiz > Edit" page And I change quiz section heading "" to "This is section one" And I change quiz section heading "Heading 2" to "This is section two" Then I should see "This is section one" And I should see "This is section two" @javascript Scenario: Set section headings to blanks Given the following "activities" exist: | activity | name | intro | course | idnumber | | quiz | Quiz 1 | Quiz 1 description | C1 | quiz1 | And the following "questions" exist: | questioncategory | qtype | name | questiontext | | Test questions | truefalse | TF1 | This is question 01 | | Test questions | truefalse | TF2 | This is question 02 | | Test questions | truefalse | TF3 | This is question 03 | | Test questions | truefalse | TF4 | This is question 04 | | Test questions | truefalse | TF5 | This is question 05 | And quiz "Quiz 1" contains the following questions: | question | page | | TF1 | 1 | | TF2 | 2 | | TF3 | 3 | | TF4 | 3 | And quiz "Quiz 1" contains the following sections: | heading | firstslot | shuffle | | Heading 1 | 1 | 0 | | Heading 2 | 2 | 0 | | Heading 3 | 3 | 1 | And I am on the "Quiz 1" "mod_quiz > Edit" page When I change quiz section heading "Heading 1" to "" Then I should not see "Heading 1" And I should see "Heading 2" And I should see "Heading 3" And I change quiz section heading "Heading 2" to "" And I should not see "Heading 1" And I should not see "Heading 2" And I should see "Heading 3" And I change quiz section heading "Heading 3" to "" And I should not see "Heading 1" And I should not see "Heading 2" And I should not see "Heading 3" @javascript Scenario: Remove a section Given the following "activities" exist: | activity | name | intro | course | idnumber | | quiz | Quiz 1 | Quiz 1 description | C1 | quiz1 | And the following "questions" exist: | questioncategory | qtype | name | questiontext | | Test questions | truefalse | TF1 | This is question 01 | | Test questions | truefalse | TF2 | This is question 02 | | Test questions | truefalse | TF3 | This is question 03 | And quiz "Quiz 1" contains the following questions: | question | page | | TF1 | 1 | | TF2 | 2 | | TF3 | 3 | And quiz "Quiz 1" contains the following sections: | heading | firstslot | shuffle | | Heading 1 | 1 | 0 | | Heading 2 | 2 | 0 | | Heading 3 | 3 | 1 | When I am on the "Quiz 1" "mod_quiz > Edit" page And I follow "Remove heading 'Heading 2'" And I should see "Are you sure you want to remove the 'Heading 2' section heading?" And I click on "Yes" "button" in the "Confirm" "dialogue" And I wait until the page is ready And I wait until "Heading 2" "text" does not exist Then I should see "Heading 1" And I should not see "Heading 2" And I should see "Heading 3" @javascript Scenario: The edit-icon tool-tips are updated when a section is edited Given the following "activities" exist: | activity | name | intro | course | idnumber | | quiz | Quiz 1 | Quiz 1 description | C1 | quiz1 | And the following "questions" exist: | questioncategory | qtype | name | questiontext | | Test questions | truefalse | TF1 | This is question 01 | | Test questions | truefalse | TF2 | This is question 02 | And quiz "Quiz 1" contains the following questions: | question | page | | TF1 | 1 | | TF2 | 2 | And quiz "Quiz 1" contains the following sections: | heading | firstslot | shuffle | | Heading 1 | 1 | 0 | | Heading 2 | 2 | 0 | When I am on the "Quiz 1" "mod_quiz > Edit" page And I change quiz section heading "Heading 2" to "Edited heading" Then I should see "Edited heading" And "Edit heading 'Edited heading'" "link" should be visible And "Remove heading 'Edited heading'" "link" should be visible @javascript Scenario: Moving a question up from section 3 to the first section. Given the following "activities" exist: | activity | name | intro | course | idnumber | | quiz | Quiz 1 | Quiz 1 description | C1 | quiz1 | And the following "questions" exist: | questioncategory | qtype | name | questiontext | | Test questions | truefalse | TF1 | This is question 01 | | Test questions | truefalse | TF2 | This is question 02 | | Test questions | truefalse | TF3 | This is question 03 | | Test questions | truefalse | TF4 | This is question 04 | | Test questions | truefalse | TF5 | This is question 05 | | Test questions | truefalse | TF6 | This is question 06 | And quiz "Quiz 1" contains the following questions: | question | page | | TF1 | 1 | | TF2 | 2 | | TF3 | 3 | | TF4 | 4 | | TF5 | 5 | | TF6 | 6 | And quiz "Quiz 1" contains the following sections: | heading | firstslot | shuffle | | Heading 1 | 1 | 0 | | Heading 2 | 3 | 0 | | Heading 3 | 5 | 1 | When I am on the "Quiz 1" "mod_quiz > Edit" page And I move "TF5" to "After Question 2" in the quiz by clicking the move icon Then I should see "TF5" on quiz page "2" @javascript Scenario: Moving a question down from the first section to the second section. Given the following "activities" exist: | activity | name | intro | course | idnumber | | quiz | Quiz 1 | Quiz 1 description | C1 | quiz1 | And the following "questions" exist: | questioncategory | qtype | name | questiontext | | Test questions | truefalse | TF1 | This is question 01 | | Test questions | truefalse | TF2 | This is question 02 | | Test questions | truefalse | TF3 | This is question 03 | | Test questions | truefalse | TF4 | This is question 04 | | Test questions | truefalse | TF5 | This is question 05 | | Test questions | truefalse | TF6 | This is question 06 | And quiz "Quiz 1" contains the following questions: | question | page | | TF1 | 1 | | TF2 | 2 | | TF3 | 3 | | TF4 | 4 | | TF5 | 5 | | TF6 | 6 | And quiz "Quiz 1" contains the following sections: | heading | firstslot | shuffle | | Heading 1 | 1 | 0 | | Heading 2 | 3 | 0 | | Heading 3 | 5 | 1 | When I am on the "Quiz 1" "mod_quiz > Edit" page And I move "TF1" to "After Question 3" in the quiz by clicking the move icon Then I should see "TF1" on quiz page "2" @javascript Scenario: I should not see a delete icon for the first section in the quiz. Given the following "activities" exist: | activity | name | intro | course | idnumber | | quiz | Quiz 1 | Quiz 1 description | C1 | quiz1 | And the following "questions" exist: | questioncategory | qtype | name | questiontext | | Test questions | truefalse | TF1 | This is question 01 | | Test questions | truefalse | TF2 | This is question 02 | | Test questions | truefalse | TF3 | This is question 03 | And quiz "Quiz 1" contains the following questions: | question | page | | TF1 | 1 | | TF2 | 2 | | TF3 | 3 | And quiz "Quiz 1" contains the following sections: | heading | firstslot | shuffle | | Heading 1 | 1 | 0 | | Heading 2 | 2 | 0 | | Heading 3 | 3 | 1 | When I am on the "Quiz 1" "mod_quiz > Edit" page Then "Remove heading 'Heading 1'" "link" should not exist And "Remove heading 'Heading 2'" "link" should exist And "Remove heading 'Heading 3'" "link" should exist @javascript Scenario: Turn shuffling on for a section Given the following "activities" exist: | activity | name | intro | course | idnumber | | quiz | Quiz 1 | Quiz 1 description | C1 | quiz1 | And the following "questions" exist: | questioncategory | qtype | name | questiontext | | Test questions | truefalse | TF1 | This is question 01 | | Test questions | truefalse | TF2 | This is question 02 | | Test questions | truefalse | TF3 | This is question 03 | And quiz "Quiz 1" contains the following questions: | question | page | | TF1 | 1 | | TF2 | 2 | | TF3 | 3 | And quiz "Quiz 1" contains the following sections: | heading | firstslot | shuffle | | Heading 1 | 1 | 0 | | Heading 2 | 2 | 0 | | Heading 3 | 3 | 0 | When I am on the "Quiz 1" "mod_quiz > Edit" page And I click on shuffle for section "Heading 1" on the quiz edit page And I click on shuffle for section "Heading 2" on the quiz edit page Then shuffle for section "Heading 1" should be "On" on the quiz edit page And shuffle for section "Heading 2" should be "On" on the quiz edit page @javascript Scenario: Turn shuffling off for a section Given the following "activities" exist: | activity | name | intro | course | idnumber | | quiz | Quiz 1 | Quiz 1 description | C1 | quiz1 | And the following "questions" exist: | questioncategory | qtype | name | questiontext | | Test questions | truefalse | TF1 | This is question 01 | | Test questions | truefalse | TF2 | This is question 02 | | Test questions | truefalse | TF3 | This is question 03 | And quiz "Quiz 1" contains the following questions: | question | page | | TF1 | 1 | | TF2 | 2 | | TF3 | 3 | And quiz "Quiz 1" contains the following sections: | heading | firstslot | shuffle | | Heading 1 | 1 | 1 | | Heading 2 | 2 | 1 | | Heading 3 | 3 | 1 | When I am on the "Quiz 1" "mod_quiz > Edit" page And I click on shuffle for section "Heading 1" on the quiz edit page And I click on shuffle for section "Heading 2" on the quiz edit page Then shuffle for section "Heading 1" should be "Off" on the quiz edit page And shuffle for section "Heading 2" should be "Off" on the quiz edit page And I reload the page And shuffle for section "Heading 1" should be "Off" on the quiz edit page And shuffle for section "Heading 2" should be "Off" on the quiz edit page @javascript Scenario: Add section heading option only appears for pages that are not the first in their section. Given the following "activities" exist: | activity | name | intro | course | idnumber | | quiz | Quiz 1 | Quiz 1 description | C1 | quiz1 | And the following "questions" exist: | questioncategory | qtype | name | questiontext | | Test questions | truefalse | TF1 | This is question 01 | | Test questions | truefalse | TF2 | This is question 02 | | Test questions | truefalse | TF3 | This is question 03 | And quiz "Quiz 1" contains the following questions: | question | page | | TF1 | 1 | | TF2 | 1 | | TF3 | 2 | When I am on the "Quiz 1" "mod_quiz > Edit" page And I click on the "Add" page break icon after question "TF1" And I open the action menu in "Page 1" "list_item" Then "a new section heading" "link" in the "Page 1" "list_item" should not be visible # Click away to close the menu. And I click on ".numberofquestions" "css_element" And I open the action menu in "Page 2" "list_item" And "a new section heading" "link" in the "Page 2" "list_item" should be visible And I click on ".numberofquestions" "css_element" And I open the action menu in "Page 3" "list_item" And "a new section heading" "link" in the "Page 3" "list_item" should be visible And I click on ".numberofquestions" "css_element" And I open the action menu in ".last-add-menu" "css_element" And "a new section heading" "link" in the ".last-add-menu" "css_element" should not be visible @javascript Scenario: Verify sections are added in the right place afte ajax changes Given the following "activities" exist: | activity | name | intro | course | idnumber | | quiz | Quiz 1 | Quiz 1 description | C1 | quiz1 | And the following "questions" exist: | questioncategory | qtype | name | questiontext | | Test questions | truefalse | TF1 | This is question 01 | | Test questions | truefalse | TF2 | This is question 02 | | Test questions | truefalse | TF3 | This is question 03 | | Test questions | truefalse | TF4 | This is question 04 | And quiz "Quiz 1" contains the following questions: | question | page | | TF1 | 1 | | TF2 | 2 | | TF3 | 3 | | TF4 | 4 | When I am on the "Quiz 1" "mod_quiz > Edit" page And I click on the "Remove" page break icon after question "TF1" And I open the "Page 2" add to quiz menu And I choose "a new section heading" in the open action menu Then "TF3" "list_item" should exist in the "New heading" "list_item" @javascript Scenario: Add section works after removing a page break with more than 10 pages Given the following "activities" exist: | activity | name | intro | course | idnumber | | quiz | Quiz 1 | Quiz 1 description | C1 | quiz1 | And the following "questions" exist: | questioncategory | qtype | name | questiontext | | Test questions | truefalse | TF1 | Question 1 | | Test questions | truefalse | TF2 | Question 2 | | Test questions | truefalse | TF3 | Question 3 | | Test questions | truefalse | TF4 | Question 4 | | Test questions | truefalse | TF5 | Question 5 | | Test questions | truefalse | TF6 | Question 6 | | Test questions | truefalse | TF7 | Question 7 | | Test questions | truefalse | TF8 | Question 8 | | Test questions | truefalse | TF9 | Question 9 | | Test questions | truefalse | TF10 | Question 10 | | Test questions | truefalse | TF11 | Question 11 | And quiz "Quiz 1" contains the following questions: | question | page | | TF1 | 1 | | TF2 | 2 | | TF3 | 3 | | TF4 | 4 | | TF5 | 5 | | TF6 | 6 | | TF7 | 7 | | TF8 | 8 | | TF9 | 9 | | TF10 | 10 | | TF11 | 11 | When I am on the "Quiz 1" "mod_quiz > Edit" page And I click on the "Remove" page break icon after question "TF10" And I open the "Page 10" add to quiz menu And I choose "a new section heading" in the open action menu Then "TF10" "list_item" should exist in the "New heading" "list_item" PK9B\ZM*tests/behat/manually_mark_question.featurenu[@mod @mod_quiz Feature: Teachers can override the grade for any question As a teacher In order to correct errors I must be able to override the grades that Moodle gives. Background: Given the following "users" exist: | username | firstname | lastname | email | | teacher1 | Teacher | 1 | teacher1@example.com | | student1 | Student | 1 | student0@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 | intro | course | idnumber | grade | | quiz | Quiz 1 | Quiz 1 description | C1 | quiz1 | 20 | And the following "question categories" exist: | contextlevel | reference | name | | Activity module | quiz1 | Test questions | And the following "questions" exist: | questioncategory | qtype | name | questiontext | defaultmark | | Test questions | essay | TF1 | First question | 20 | And quiz "Quiz 1" contains the following questions: | question | page | | TF1 | 1 | And the following "user private files" exist: | user | filepath | | teacher1 | mod/quiz/tests/fixtures/moodle_logo.jpg | And I am on the "Quiz 1" "mod_quiz > View" page logged in as "student1" And I press "Attempt quiz" And I follow "Finish attempt ..." And I press "Submit all and finish" And I click on "Submit all and finish" "button" in the "Submit all your answers and finish?" "dialogue" And I log out @javascript @_switch_window @_bug_phantomjs Scenario: Validating the marking of an essay question attempt. When I am on the "Quiz 1 > student1 > Attempt 1" "mod_quiz > Attempt review" page logged in as "teacher1" And I follow "Make comment or override mark" And I switch to "commentquestion" window And I set the field "Mark" to "25" And I press "Save" Then I should see "This grade is outside the valid range." And I set the field "Mark" to "aa" And I press "Save" And I should see "That is not a valid number." And I set the field "Mark" to "10.0" And I press "Save" and switch to main window And I should see "Complete" in the "Manually graded 10 with comment: " "table_row" And I follow "Make comment or override mark" And I switch to "commentquestion" window And I should see "Teacher 1" in the "Manually graded 10 with comment: " "table_row" @javascript @_switch_window @_file_upload @_bug_phantomjs @editor_tiny Scenario: Comment on a response to an essay question attempt. When I log in as "teacher1" And I am on the "Quiz 1 > student1 > Attempt 1" "mod_quiz > Attempt review" page And I follow "Make comment or override mark" And I switch to "commentquestion" window And "Summary of attempt" "table" should exist And I set the field "Comment" to "Administrator's comment" And I select the "p" element in position "0" of the "Comment" TinyMCE editor And I click on the "Image" button for the "Comment" TinyMCE editor And I click on "Browse repositories" "button" in the "Insert image" "dialogue" And I click on "Private files" "link" in the ".fp-repo-area" "css_element" And I click on "moodle_logo.jpg" "link" And I click on "Select this file" "button" And I set the field "How would you describe this image to someone who can't see it?" to "It's the logo" And I click on "Save" "button" in the "Image details" "dialogue" And I press "Save" and switch to main window And I switch to the main window Then I should see "Commented: [It's the logo]" in the ".history table" "css_element" And "//img[contains(@src, 'moodle_logo.jpg')]" "xpath_element" should exist in the ".comment" "css_element" # This time is same as time the window is open. So wait for it to close before proceeding. And I wait "2" seconds PK9B\}UU3tests/behat/editing_set_marks_with_attempts.featurenu[@mod @mod_quiz Feature: Edit quiz marks with attempts In order to create a quiz that awards marks the way I want As a teacher I must be able to set the marks I want on the Edit quiz page (even after the quiz has been attempted). Background: Given the following "users" exist: | username | firstname | lastname | email | | teacher1 | T1 | Teacher1 | teacher1@example.com | | student1 | S1 | Student1 | student1@example.com | And the following "courses" exist: | fullname | shortname | category | | Course 1 | C1 | 0 | And the following "course enrolments" exist: | user | course | role | | teacher1 | C1 | editingteacher | | student1 | C1 | student | And the following "activities" exist: | activity | name | course | idnumber | grade | decimalpoints | questiondecimalpoints | | quiz | Quiz 1 | C1 | quiz1 | 20 | 2 | -1 | And I log in as "teacher1" And I add a "True/False" question to the "Quiz 1" quiz with: | Question name | First question | | Question text | Answer me | | Default mark | 2.0 | And I add a "True/False" question to the "Quiz 1" quiz with: | Question name | Second question | | Question text | Answer again | | Default mark | 3.0 | And I log out And I am on the "Quiz 1" "mod_quiz > View" page logged in as "student1" And I press "Attempt quiz" And I log out And I log in as "teacher1" And I am on the "Quiz 1" "mod_quiz > Edit" page @javascript Scenario: Set the max mark for a question. When I set the max mark for question "First question" to "7.0" Then I should see "7.00" And I should see "3.00" And I should see "Total of marks: 10.00" When I follow "Edit maximum mark" And I press the escape key Then I should see "7.00" And I should see "3.00" And I should see "Total of marks: 10.00" And "li input[name=maxmark]" "css_element" should not exist @javascript Scenario: Set the overall Maximum grade. When I set the field "maxgrade" to "10.0" And I press "savechanges" Then the field "maxgrade" matches value "10.00" And I should see "2.00" And I should see "3.00" And I should see "Total of marks: 5.00" @javascript Scenario: Verify the number of decimal places shown is what the quiz settings say it should be. Given I change window size to "large" # Then the field "maxgrade" matches value "20.00" -- with exact match on decimal places. And "//input[@name = 'maxgrade' and @value = '20.00']" "xpath_element" should exist And I should see "2.00" And I should see "3.00" And I should see "Total of marks: 5.00" And I should not see "2.000" And I should not see "3.000" And I should not see "Total of marks: 5.000" When I am on the "Quiz 1" "quiz activity editing" page And I set the following fields to these values: | Decimal places in grades | 3 | | Decimal places in marks for questions | 5 | And I press "Save and display" And I am on the "Quiz 1" "mod_quiz > Edit" page # Then the field "maxgrade" matches value "20.000" -- with exact match on decimal places. Then "//input[@name = 'maxgrade' and @value = '20.000']" "xpath_element" should exist And I should see "2.00000" And I should see "3.00000" And I should see "Total of marks: 5.000" And I should not see "2.000000" And I should not see "3.000000" And I should not see "Total of marks: 5.0000" PK9B\LΙzz'tests/behat/quiz_attempt_blocks.featurenu[@mod @mod_quiz Feature: Blocks can be displayed on quiz attempt pages In order to see blocks on quiz attempt pages As a teacher I need to be able to set blocks display settings for quiz Background: Given the following "users" exist: | username | | student | And the following "courses" exist: | fullname | shortname | | Course 1 | C1 | And the following "course enrolments" exist: | user | course | role | | student | C1 | student | Scenario Outline: Blocks display on quiz attempt pages can be toggled Given the following "activities" exist: | activity | course | name | idnumber | showblocks | | quiz | C1 | Quiz 1 | q1 | | And the following "question categories" exist: | contextlevel | reference | name | | Activity module | q1 | Test questions | And the following "questions" exist: | questioncategory | qtype | name | | Test questions | truefalse | TF1 | And quiz "Quiz 1" contains the following questions: | question | page | | TF1 | 1 | And the following "blocks" exist: | blockname | contextlevel | reference | pagetypepattern | defaultregion | | comments | Activity module | q1 | mod-quiz-attempt | side-pre | When I am on the "Quiz 1" "quiz activity" page logged in as student And I press "Attempt quiz" Then "Comments" "block" exist Examples: | blockdisplay | blockvisibility | | 1 | should | | 0 | should not | PK9B\5tests/behat/quiz_activity_completion_unlocked.featurenu[@mod @mod_quiz @core_completion Feature: Activity completion in the quiz activity with unlocked and re-grading. In order to have visibility of quiz completion requirements As a student I need to be able to view my quiz completion progress even teacher have re-grading the grade pass. Background: Given the following "users" exist: | username | firstname | lastname | email | | student1 | Student | 1 | student1@example.com | | teacher1 | Teacher | 1 | teacher1@example.com | And the following "courses" exist: | fullname | shortname | category | enablecompletion | | Course 1 | C1 | 0 | 1 | And the following "course enrolments" exist: | user | course | role | | teacher1 | C1 | editingteacher | | student1 | C1 | student | And the following "activity" exists: | activity | quiz | | course | C1 | | idnumber | quiz1 | | name | Test quiz name | | section | 1 | | gradepass | 8 | | grade | 10 | | grademethod | 1 | | completion | 2 | | completionusegrade | 1 | | completionpassgrade | 1 | And the following "question categories" exist: | contextlevel | reference | name | | Activity module | quiz1 | Test questions | And the following "questions" exist: | questioncategory | qtype | name | questiontext | defaultmark | | Test questions | truefalse | First question | Answer the first question | 8 | | Test questions | truefalse | Second question | Answer the second question | 2 | And quiz "Test quiz name" contains the following questions: | question | page | | First question | 1 | | Second question | 2 | @javascript Scenario: Student will receive correct completion even when teacher unlocked completion and re-grading. Given I am on the "Test quiz name" "quiz activity" page logged in as student1 And the "Receive a grade" completion condition of "Test quiz name" is displayed as "todo" And the "Receive a passing grade" completion condition of "Test quiz name" is displayed as "todo" And user "student1" has attempted "Test quiz name" with responses: | slot | response | | 1 | True | | 2 | False | And I am on "Course 1" course homepage And I follow "Test quiz name" And the "Receive a grade" completion condition of "Test quiz name" is displayed as "done" And the "Receive a passing grade" completion condition of "Test quiz name" is displayed as "done" And I log out When I am on the "Course 1" course page logged in as teacher1 And I navigate to "Reports > Activity completion" in current page administration And "Completed (achieved pass grade)" "icon" should exist in the "Student 1" "table_row" And I am on the "Test quiz name" "quiz activity" page And I navigate to "Settings" in current page administration And I expand all fieldsets And I press "Unlock completion settings" And I set the following fields to these values: | gradepass | 10 | And I press "Save and return to course" And I navigate to "Reports > Activity completion" in current page administration Then "Completed (achieved pass grade)" "icon" should not exist in the "Student 1" "table_row" And I log out And I am on the "Test quiz name" "quiz activity" page logged in as student1 And the "Receive a grade" completion condition of "Test quiz name" is displayed as "done" And the "Receive a passing grade" completion condition of "Test quiz name" is displayed as "failed" PK9B\yۗc*tests/behat/attempt_review_options.featurenu[@mod @mod_quiz Feature: Allow settings to show Max marks and Marks, Max marks only, or hide the grade information completely. As a teacher In order to decide how grade review options are displayed on questions in a quiz review page I need to be able to set the grade review options for a quiz to to show Max and Marks, Max only, or hide the grade information. Background: Given the following "users" exist: | username | firstname | lastname | email | | student1 | Student | One | student1@example.com | | teacher | Teacher | One | teacher@example.com | And the following "courses" exist: | fullname | shortname | category | | Course 1 | C1 | 0 | And the following "course enrolments" exist: | user | course | role | | teacher | C1 | editingteacher | | student1 | C1 | student | 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 | TF1 | First question | And the following "activities" exist: | activity | name | intro | course | idnumber | maxmarksduring | marksduring | maxmarksimmediately | marksimmediately | preferredbehaviour | | quiz | Quiz 1 | Quiz 1 test | C1 | quiz1 | 1 | 1 | 1 | 1 | immediatefeedback | | quiz | Quiz 2 | Quiz 2 test | C1 | quiz2 | 0 | 0 | 1 | 1 | immediatefeedback | And quiz "Quiz 1" contains the following questions: | question | page | maxmark | | TF1 | 1 | 2.00 | And quiz "Quiz 2" contains the following questions: | question | page | maxmark | | TF1 | 1 | 2.00 | @javascript Scenario: Show max marks and marks during and immediately after the attempt. Given I am on the "Quiz 1" "quiz activity" page logged in as "student1" And I click on "Attempt quiz" "button" And I should see "Question 1" in the ".info" "css_element" And I should see "Not complete" in the ".info" "css_element" And I should see "Marked out of 2.00" in the ".info" "css_element" And I set the field "True" to "1" And I press "Finish attempt ..." And I press "Submit all and finish" And I click on "Submit all and finish" "button" in the "Submit all your answers and finish?" "dialogue" Then I should see "Finished" in the "Status" "table_row" And I should see "Question 1" in the ".info" "css_element" And I should see "Correct" in the ".info" "css_element" And I should see "Mark 2.00 out of 2.00" in the ".info" "css_element" And I am on the "Quiz 2" "quiz activity" page And I click on "Attempt quiz" "button" And I should see "Question 1" in the ".info" "css_element" And I should see "Not complete" in the ".info" "css_element" And I should not see "Marked out of 2.00" in the ".info" "css_element" And I set the field "True" to "1" And I press "Finish attempt ..." And I press "Submit all and finish" And I click on "Submit all and finish" "button" in the "Submit all your answers and finish?" "dialogue" And I should see "Finished" in the "Status" "table_row" And I should see "Question 1" in the ".info" "css_element" And I should see "Correct" in the ".info" "css_element" And I should see "Mark 2.00 out of 2.00" in the ".info" "css_element" # Check the page that lists all the quizzes in a course. And I am on the "C1" "quiz index" page And I should see "Quiz 1" And I should see "Quiz 2" PK9B\T9n6tests/behat/completion_condition_passing_grade.featurenu[@mod @mod_quiz @core_completion Feature: Set a quiz to be marked complete when the student passes In order to ensure a student has learned the material before being marked complete As a teacher I need to set a quiz to complete when the student recieves a passing grade Background: Given the following "users" exist: | username | firstname | lastname | email | | student1 | Student | 1 | student1@example.com | | teacher1 | Teacher | 1 | teacher1@example.com | And the following "courses" exist: | fullname | shortname | category | enablecompletion | | Course 1 | C1 | 0 | 1 | And the following "course enrolments" exist: | user | course | role | | teacher1 | C1 | editingteacher | | student1 | C1 | student | And the following "question categories" exist: | contextlevel | reference | name | | Course | C1 | Test questions | And the following "questions" exist: | questioncategory | qtype | name | questiontext | | Test questions | truefalse | First question | Answer the first question | And the following "activities" exist: | activity | name | course | idnumber | attempts | gradepass | completion | completionusegrade | completionpassgrade | completionview | | quiz | Test quiz name | C1 | quiz1 | 4 | 5.00 | 2 | 1 | 1 | 1 | And quiz "Test quiz name" contains the following questions: | question | page | | First question | 1 | @javascript Scenario: student1 passes on the first try When I log in as "student1" And I am on "Course 1" course homepage And the "Receive a grade" completion condition of "Test quiz name" is displayed as "todo" And the "Receive a passing grade" completion condition of "Test quiz name" is displayed as "todo" And the "View" completion condition of "Test quiz name" is displayed as "todo" And user "student1" has attempted "Test quiz name" with responses: | slot | response | | 1 | True | And I follow "Test quiz name" Then the "Receive a grade" completion condition of "Test quiz name" is displayed as "done" And the "Receive a passing grade" completion condition of "Test quiz name" is displayed as "done" And the "View" completion condition of "Test quiz name" is displayed as "done" And I am on "Course 1" course homepage And the "Receive a grade" completion condition of "Test quiz name" is displayed as "done" And the "Receive a passing grade" completion condition of "Test quiz name" is displayed as "done" And the "View" completion condition of "Test quiz name" is displayed as "done" And I log out And I log in as "teacher1" And I am on "Course 1" course homepage And I navigate to "Reports" in current page administration And I click on "Activity completion" "link" And "Completed" "icon" should exist in the "Student 1" "table_row" Scenario Outline: Verify that gradepass, together with completionpassgrade are validated correctly Given the following "language customisations" exist: | component | stringid | value | | core_langconfig | decsep | | And the following "activity" exist: | activity | name | course | idnumber | gradepass | completion | completionpassgrade | | quiz | Oh, grades, passgrades and floats| C1 | ohgrades | | 2 | | When I am on the "ohgrades" "quiz activity editing" page logged in as "teacher1" And I expand all fieldsets And I set the field "Grade to pass" to "" And I set the field "Add requirements" to "1" And I set the field "Receive a grade" to "1" And I set the field "" to "1" And I press "Save and display" Then I should see "" And I should not see "" Examples: | gradepass | completionpassgrade | decsep | seen | notseen | outcome | | | Any grade | . | method: Highest | Save and display | ok | | | Passing grade | . | does not have a valid | method: Highest | completion-err | | 0 | Any grade | . | method: Highest | Save and display | ok | | 0 | Passing grade | . | does not have a valid | method: Highest | completion-err | | aaa | Any grade | . | must enter a number | method: Highest | number-err | | aaa | Passing grade | . | must enter a number | method: Highest | number-err | | 200 | Any grade | . | can not be greater | method: Highest | grade-big-err | | 200 | Passing grade | . | can not be greater | method: Highest | grade-big-err | | 5.55 | Any grade | . | 5.55 out of 100 | Save and display | ok | | 5.55 | Passing grade | . | 5.55 out of 100 | Save and display | ok | | 5#55 | Any grade | . | must enter a number | method: Highest | number-err | | 5#55 | Passing grade | . | must enter a number | method: Highest | number-err | | 5#55 | Any grade | # | 5#55 out of 100 | Save and display | ok | | 5#55 | Passing grade | # | 5#55 out of 100 | Save and display | ok | PK9B\PS‡-tests/behat/view_grade_recover_grades.featurenu[@mod @mod_quiz Feature: Testing view quiz grade feedback with recover grades setting As a user I want the quiz grade and completion status to reflect the recover grades setting used when re-enrolling a user Background: Given the following "users" exist: | username | firstname | lastname | email | | teacher | Teacher | One | teacher@example.com | | student | Student | One | student@example.com | And the following "courses" exist: | fullname | shortname | category | enablecompletion | | Course 1 | C1 | 0 | 1 | And the following "course enrolments" exist: | user | course | role | | teacher | C1 | editingteacher | | student | C1 | student | And the following "activities" exist: | activity | name | intro | course | idnumber | completion | completionusegrade | | quiz | Quiz 1 | Quiz 1 description | C1 | quiz1 | 2 | 1 | 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 | TF1 | First question | And quiz "Quiz 1" contains the following questions: | question | page | | TF1 | 1 | And user "student" has attempted "Quiz 1" with responses: | slot | response | | 1 | True | Scenario Outline: Teachers can view grade feedback and score regardless of recover grades setting Given the following config values are set as admin: | recovergradesdefault | | And user "teacher" has attempted "Quiz 1" with responses: | slot | response | | 1 | True | When I am on the "Quiz 1" "mod_quiz > View" page logged in as "teacher" # Grade feedback and grade score should be the same. Recover grades setting should not affect the score Then I should see "Highest grade: 100.00 / 100.00" And I should see "100.00 out of 100.00" in the "Grade" "table_row" Examples: | recovergradesetting | | 0 | | 1 | Scenario Outline: View quiz grade feedback and score with recover grades settings Given the following config values are set as admin: | recovergradesdefault | | When I am on the "Quiz 1" "mod_quiz > View" page logged in as "student" # Grade feedback and grade score should be the same. Recover grades setting should not affect users who are not unenrolled Then I should see "Highest grade: 100.00 / 100.00" And I should see "100.00 out of 100.00" in the "Grade" "table_row" And I should see "Done: Receive a grade" in the "[data-region='completion-info']" "css_element" Examples: | recovergradesetting | | 0 | | 1 | @javascript Scenario Outline: View quiz after unenrolling and re-enrolling user Given the following config values are set as admin: | recovergradesdefault | | And I log in as "teacher" And I am on "Course 1" course homepage And I navigate to course participants And I click on "Unenrol" "icon" in the "Student One" "table_row" And I click on "Unenrol" "button" in the "Unenrol" "dialogue" And the following "course enrolments" exist: | user | course | role | | student | C1 | student | When I am on the "Quiz 1" "mod_quiz > View" page logged in as "student" Then I should see "" in the "[id='feedback']" "css_element" And I should see "100.00 out of 100.00" in the "Grade" "table_row" And I should see "" in the "[data-region='completion-info']" "css_element" # Re-attempt the quiz And I press "Re-attempt quiz" And I should see "First question" And I click on "True" "radio" in the "First question" "question" And I click on "Finish attempt ..." "button" in the "region-main" "region" And I press "Submit all and finish" And I click on "Submit all and finish" "button" in the "Submit all your answers and finish?" "dialogue" And I follow "Finish review" And I should see "Highest grade: 100.00 / 100.00" in the "[id='feedback']" "css_element" Examples: | recovergradesetting | overallgradefeedback | mycompletionstatus | | 0 | Highest grade: Not yet graded / 100.00 | To do: Receive a grade | | 1 | Highest grade: 100.00 / 100.00 | Done: Receive a grade | PK9B\&cII-tests/behat/feedback_on_override_mark.featurenu[@mod @mod_quiz @quiz Feature: Teachers see correct answers when overriding marks As a teacher In order to correct errors I must be able to override the grades that Moodle gives. Background: Given the following "users" exist: | username | firstname | lastname | email | | teacher1 | Teacher | 1 | teacher1@example.com | | student1 | Student | 1 | student0@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 | 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 | generalfeedback | | Test questions | shortanswer | Kermit | What kind of animal is Kermit? | | And quiz "Quiz 1" contains the following questions: | question | page | | Kermit | 1 | And I am on the "Quiz 1" "mod_quiz > View" page logged in as "student1" And I press "Attempt quiz" And I set the field "Answer" to "lion" And I follow "Finish attempt ..." And I press "Submit all and finish" And I click on "Submit all and finish" "button" in the "Submit all your answers and finish?" "dialogue" And I log out @javascript @_switch_window @_bug_phantomjs Scenario: Override marking of a short answer question attempt. When I am on the "Quiz 1 > student1 > Attempt 1" "mod_quiz > Attempt review" page logged in as "teacher1" And I follow "Make comment or override mark" And I switch to "commentquestion" window Then I should see "The correct answer is: frog" PK9B\eM.tests/behat/quiz_activity_availability.featurenu[@mod @mod_quiz Feature: Quiz availability can be set In order to see quiz availability As a teacher I need to be able to set quiz opening and closing times Background: Given the following "users" exist: | username | firstname | lastname | email | | teacher1 | Teacher | One | teacher1@example.com | | student1 | Student | One | student1@example.com | And the following "courses" exist: | fullname | shortname | | Course 1 | C1 | And the following "course enrolments" exist: | user | course | role | | teacher1 | C1 | editingteacher | | student1 | C1 | student | 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 | questiontext | | Test questions | truefalse | TF1 | First question | Scenario Outline: Set quiz opening time while closing time is disabled Given the following "activities" exist: | activity | course | name | timeopen | | quiz | C1 | Quiz 1 | | And quiz "Quiz 1" contains the following questions: | question | page | maxmark | | TF1 | 1 | 2 | When I am on the "Quiz 1" "quiz activity" page logged in as student1 # Confirm display as student depending on case. Then I should see ":" And I should see "%A, %d %B %Y, %I:%M##" And I should not see "Close:" And I see "This quiz is currently not available." And "Attempt quiz" "button" exist Examples: | opentext | timeopen | attemptvisibility | quizavailability | # Case 1 - open is set to future date, close is disabled. | Opens | ##tomorrow## | should not | should | # Case 4 - open is set to past date, close is disabled. | Opened | ##yesterday## | should | should not | Scenario Outline: Set quiz closing time while opening time is disabled Given the following "activities" exist: | activity | course | name | timeclose | | quiz | C1 | Quiz 1 | | And quiz "Quiz 1" contains the following questions: | question | page | maxmark | | TF1 | 1 | 2 | When I am on the "Quiz 1" "quiz activity" page logged in as student1 # Confirm display as student depending on case. Then I should see ":" And I should see "%A, %d %B %Y, %I:%M##" And I see "This quiz is currently not available." And "Attempt quiz" "button" exist Examples: | closetext | timeclose | attemptvisibility | quizavailability | # Case 2 - open is disabled, close is set to past date. | Closed | ##yesterday## | should not | should not | # Case 5 - open is disabled, close is set to future date. | Closes | ##tomorrow## | should | should not | Scenario Outline: Set quiz opening and closing times Given the following "activities" exist: | activity | course | name | timeopen | timeclose | | quiz | C1 | Quiz 1 | | | And quiz "Quiz 1" contains the following questions: | question | page | maxmark | | TF1 | 1 | 2 | When I am on the "Quiz 1" "quiz activity" page logged in as student1 # Confirm display as student depending on case. Then I should see ":" And I should see "%A, %d %B %Y, %I:%M##" And I should see ":" And I should see "%A, %d %B %Y, %I:%M##" And I see "This quiz is currently not available." And "Attempt quiz" "button" exist Examples: | opentext | timeopen | closetext | timeclose | attemptvisibility | quizavailability | # Case 6 - open and close are set to past date. | Opened | ##3 days ago## | Closed | ##yesterday## | should not | should not | # Case 7 - open is set to past date, close is set to future date. | Opened | ##yesterday## | Closes | ##tomorrow## | should | should not | # Case 8 - open and close are set to future date | Opens | ##tomorrow## | Closes | ##+2 days## | should not | should | Scenario: Quiz time open and time close are disabled # Case 3 - both open and close are disabled. Given the following "activities" exist: | activity | course | name | | quiz | C1 | Quiz 1 | And quiz "Quiz 1" contains the following questions: | question | page | maxmark | | TF1 | 1 | 2 | When I am on the "Quiz 1" "quiz activity" page logged in as student1 Then I should not see "Opens" And I should not see "Opened" And I should not see "Closes" And I should not see "Closed" And I should not see "This quiz is currently not available." And "Attempt quiz" "button" should exist @javascript Scenario Outline: Timer is displayed when quiz closes in less than an hour Given the following "activities" exist: | activity | course | name | timeclose | | quiz | C1 | Quiz 1 | | And quiz "Quiz 1" contains the following questions: | question | page | | TF1 | 1 | And I am on the "Quiz 1" "quiz activity" page logged in as "teacher1" When I press "Preview quiz" # Confirm timer visibility for teacher Then I see "Time left" And I am on the "Quiz 1" "quiz activity" page logged in as "student1" And I press "Attempt quiz" # Confirm timer visibility for student And I see "Time left" Examples: | closedate | timervisibility | # Case 1 - closedate is < 1hr, the timer is visible | ##now +10 minutes## | should | # Case 2 - closedate is > 1hr, the timer is not visible | ##now +2 hours## | should not | PK9B\Β8833,tests/behat/attempt_require_previous.featurenu[@mod @mod_quiz Feature: Attempt a quiz where some questions require that the previous question has been answered. In order to complete a quiz where questions require previous ones to be complete As a student I need later questions to appear once earlier ones have been answered. Background: Given the following "users" exist: | username | firstname | lastname | email | | student | Student | One | student@example.com | | teacher | Teacher | One | teacher@example.com | And 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 "course enrolments" exist: | user | course | role | | student | C1 | student | | teacher | C1 | editingteacher | And the following "question categories" exist: | contextlevel | reference | name | | Activity module | qbank1 | Test questions | @javascript Scenario Outline: A question that requires the previous one is initially blocked Given the following "questions" exist: | questioncategory | qtype | name | questiontext | | Test questions | truefalse | TF1 | First question | | Test questions | truefalse | TF2 | Second question | And the following "activities" exist: | activity | name | intro | course | idnumber | preferredbehaviour | | quiz | Quiz 1 | Quiz 1 description | C1 | quiz1 | | And quiz "Quiz 1" contains the following questions: | question | page | requireprevious | | TF1 | 1 | 0 | | TF2 | 1 | 1 | When I am on the "Quiz 1" "mod_quiz > View" page logged in as "student" And I press "Attempt quiz" Then I should see "First question" And I should see "This question cannot be attempted until the previous question has been completed." And I should not see "Second question" And I am on the "Quiz 1 > student > Attempt 1" "mod_quiz > Attempt review" page logged in as "teacher" And I should see "First question" And I should see "This question cannot be attempted until the previous question has been completed." And I should not see "Second question" And "Question 1" "link" should exist And "Question 2" "link" should not exist Examples: | quizbehaviour | | immediatefeedback | | interactive | @javascript Scenario Outline: A question is shown as blocked when previewing a quiz Given the following "questions" exist: | questioncategory | qtype | name | questiontext | | Test questions | truefalse | TF1 | First question | | Test questions | truefalse | TF2 | Second question | And the following "activities" exist: | activity | name | intro | course | idnumber | preferredbehaviour | | quiz | Quiz 1 | Quiz 1 description | C1 | quiz1 | | And quiz "Quiz 1" contains the following questions: | question | page | requireprevious | | TF1 | 1 | 0 | | TF2 | 1 | 1 | When I am on the "Quiz 1" "mod_quiz > View" page logged in as "teacher" And I press "Preview quiz" Then I should see "First question" And I should see "This question cannot be attempted until the previous question has been completed." And I should not see "Second question" Examples: | quizbehaviour | | immediatefeedback | | interactive | @javascript Scenario Outline: A question requires the previous one becomes available when the first one is answered Given the following "questions" exist: | questioncategory | qtype | name | questiontext | | Test questions | truefalse | TF1 | First question | | Test questions | truefalse | TF2 | Second question | And the following "activities" exist: | activity | name | intro | course | idnumber | preferredbehaviour | | quiz | Quiz 1 | Quiz 1 description | C1 | quiz1 | | And quiz "Quiz 1" contains the following questions: | question | page | requireprevious | | TF1 | 1 | 0 | | TF2 | 1 | 1 | When I am on the "Quiz 1" "mod_quiz > View" page logged in as "student" And I press "Attempt quiz" And I click on "True" "radio" in the "First question" "question" And I press "Check" Then I should see "First question" And I should not see "This question cannot be attempted until the previous question has been completed." And I should see "Second question" And "Question 1" "link" should exist And "Question 2" "link" should exist Examples: | quizbehaviour | | immediatefeedback | | interactive | @javascript Scenario: After quiz submitted, all questions show on the review page Given the following "questions" exist: | questioncategory | qtype | name | questiontext | | Test questions | truefalse | TF1 | First question | | Test questions | truefalse | TF2 | Second question | And the following "activities" exist: | activity | name | intro | course | idnumber | preferredbehaviour | | quiz | Quiz 1 | Quiz 1 description | C1 | quiz1 | immediatefeedback | And quiz "Quiz 1" contains the following questions: | question | page | requireprevious | | TF1 | 1 | 0 | | TF2 | 1 | 1 | When I am on the "Quiz 1" "mod_quiz > View" page logged in as "student" And I press "Attempt quiz" And I press "Finish attempt ..." And I press "Submit all and finish" And I click on "Submit all and finish" "button" in the "Submit all your answers and finish?" "dialogue" Then the state of "First question" question is shown as "Not answered" And the state of "Second question" question is shown as "Not answered" @javascript Scenario: A questions cannot be blocked in a deferred feedback quiz (despite what is set in the DB). Given the following "questions" exist: | questioncategory | qtype | name | questiontext | | Test questions | truefalse | TF1 | First question | | Test questions | truefalse | TF2 | Second question | And the following "activities" exist: | activity | name | intro | course | idnumber | preferredbehaviour | | quiz | Quiz 1 | Quiz 1 description | C1 | quiz1 | deferredfeedback | And quiz "Quiz 1" contains the following questions: | question | page | requireprevious | | TF1 | 1 | 0 | | TF2 | 1 | 1 | When I am on the "Quiz 1" "mod_quiz > View" page logged in as "student" And I press "Attempt quiz" Then I should see "First question" And I should see "Second question" And I should not see "This question cannot be attempted until the previous question has been completed." @javascript Scenario: Questions cannot be blocked in a shuffled section (despite what is set in the DB). Given the following "questions" exist: | questioncategory | qtype | name | questiontext | | Test questions | truefalse | TF1 | First question | | Test questions | truefalse | TF2 | Second question | And the following "activities" exist: | activity | name | intro | course | idnumber | preferredbehaviour | questionsperpage | | quiz | Quiz 1 | Quiz 1 description | C1 | quiz1 | immediatefeedback | 2 | And quiz "Quiz 1" contains the following questions: | question | page | requireprevious | | TF1 | 1 | 1 | | TF2 | 2 | 1 | And quiz "Quiz 1" contains the following sections: | heading | firstslot | shuffle | | Section 1 | 1 | 1 | When I am on the "Quiz 1" "mod_quiz > View" page logged in as "student" And I press "Attempt quiz" Then I should see "First question" And I should see "Second question" And I should not see "This question cannot be attempted until the previous question has been completed." @javascript Scenario: Question dependency cannot apply to the first questions in section when the previous section is shuffled Given the following "questions" exist: | questioncategory | qtype | name | questiontext | | Test questions | truefalse | TF1 | First question | | Test questions | truefalse | TF2 | Second question | And the following "activities" exist: | activity | name | intro | course | idnumber | preferredbehaviour | questionsperpage | | quiz | Quiz 1 | Quiz 1 description | C1 | quiz1 | immediatefeedback | 2 | And quiz "Quiz 1" contains the following questions: | question | page | requireprevious | | TF1 | 1 | 1 | | TF2 | 2 | 1 | And quiz "Quiz 1" contains the following sections: | heading | firstslot | shuffle | | Section 1 | 1 | 1 | | Section 2 | 2 | 0 | When I am on the "Quiz 1" "mod_quiz > View" page logged in as "student" And I press "Attempt quiz" And I press "Next page" Then I should see "Second question" And I should not see "This question cannot be attempted until the previous question has been completed." @javascript Scenario: A questions cannot be blocked in sequential quiz (despite what is set in the DB). Given the following "questions" exist: | questioncategory | qtype | name | questiontext | | Test questions | truefalse | TF1 | First question | | Test questions | truefalse | TF2 | Second question | And the following "activities" exist: | activity | name | intro | course | idnumber | preferredbehaviour | navmethod | | quiz | Quiz 1 | Quiz 1 description | C1 | quiz1 | immediatefeedback | sequential | And quiz "Quiz 1" contains the following questions: | question | page | requireprevious | | TF1 | 1 | 1 | | TF2 | 1 | 1 | When I am on the "Quiz 1" "mod_quiz > View" page logged in as "student" And I press "Attempt quiz" Then I should see "First question" And I should see "Second question" And I should not see "This question cannot be attempted until the previous question has been completed." @javascript Scenario: A questions not blocked if the previous one cannot finish, e.g. essay (despite what is set in the DB). Given the following "questions" exist: | questioncategory | qtype | name | questiontext | | Test questions | essay | Story | First question | | Test questions | truefalse | TF2 | Second question | And the following "activities" exist: | activity | name | intro | course | idnumber | preferredbehaviour | | quiz | Quiz 1 | Quiz 1 description | C1 | quiz1 | immediatefeedback | And quiz "Quiz 1" contains the following questions: | question | page | requireprevious | | Story | 1 | 0 | | TF2 | 1 | 1 | When I am on the "Quiz 1" "mod_quiz > View" page logged in as "student" And I press "Attempt quiz" Then I should see "First question" And I should see "Second question" And I should not see "This question cannot be attempted until the previous question has been completed." @javascript Scenario: A questions not blocked if the previous one cannot finish, e.g. description (despite what is set in the DB). Given the following "questions" exist: | questioncategory | qtype | name | questiontext | | Test questions | description | Info | Read me | | Test questions | truefalse | TF1 | First question | And the following "activities" exist: | activity | name | intro | course | idnumber | preferredbehaviour | | quiz | Quiz 1 | Quiz 1 description | C1 | quiz1 | immediatefeedback | And quiz "Quiz 1" contains the following questions: | question | page | requireprevious | | Info | 1 | 0 | | TF1 | 1 | 1 | When I am on the "Quiz 1" "mod_quiz > View" page logged in as "student" And I press "Attempt quiz" Then I should see "Read me" And I should see "First question" And I should not see "This question cannot be attempted until the previous question has been completed." PK9B\O0nkk&tests/behat/attempt_timed_quiz.featurenu[@mod @mod_quiz Feature: Attempt quiz with password and time limit In order to attempt a quiz with password and time limit As a teacher I need to be able to set password and time limit to quiz Background: Given the following "users" exist: | username | firstname | lastname | email | | teacher1 | Teacher | One | teacher1@example.com | | student1 | Student | One | student1@example.com | | student2 | Student | Two | student2@example.com | And the following "courses" exist: | fullname | shortname | | Course 1 | C1 | And the following "course enrolments" exist: | user | course | role | | teacher1 | C1 | editingteacher | | student1 | C1 | student | And the following "activities" exist: | activity | name | course | quizpassword | browsersecurity | timelimit | | quiz | Quiz 1 | C1 | Passw0rd | securewindow | 60 | And the following "question categories" exist: | contextlevel | reference | name | | Activity module | Quiz 1 | Test questions | And the following "questions" exist: | questioncategory | qtype | name | questiontext | | Test questions | truefalse | TF1 | First question | And quiz "Quiz 1" contains the following questions: | question | page | | TF1 | 1 | @javascript Scenario: Attempt password restricted quiz Given I am on the "Quiz 1" "quiz activity" page logged in as student1 When I press "Attempt quiz" # TODO: remove. This is an extra step due to the bug reported in MDL-81377. And I press "Start attempt" # This confirms that quiz is attempted in a separate window. And I switch to a second window # Confirm that the time limit warning is displayed. Then I should see "Your attempt will have a time limit of 1 min. When you start, the timer will begin to count down and cannot be paused. You must finish your attempt before it expires. Are you sure you wish to start now?" And I set the field "Quiz password" to "Passw0rd" And I press "Start attempt" # Confirm that quiz timer is displayed when attempting the quiz. And I should see "Time left" @javascript Scenario: Attempt quiz after entering incorrect password Given I am on the "Quiz 1" "quiz activity" page logged in as student1 When I press "Attempt quiz" # TODO: remove. This is an extra step due to the bug reported in MDL-81377. And I press "Start attempt" # This confirms that quiz is attempted in a separate window. And I switch to a second window # Incorrect password is entered. And I set the field "Quiz password" to "password" And I press "Start attempt" # Confirm that error is displayed. Then I should see "The password entered was incorrect" # Confirm that the time limit warning is displayed. And I should see "Your attempt will have a time limit of 1 min. When you start, the timer will begin to count down and cannot be paused. You must finish your attempt before it expires. Are you sure you wish to start now?" # Enter the correct password to attempt quiz. And I set the field "Quiz password" to "Passw0rd" And I press "Start attempt" # Confirm that quiz attempt proceeded. And I should not see "The password entered was incorrect" And I should not see "Your attempt will have a time limit of 1 min. When you start, the timer will begin to count down and cannot be paused. You must finish your attempt before it expires. Are you sure you wish to start now?" And I should see "Time left" PK9B\O+tests/behat/quiz_activity_certainty.featurenu[@mod @mod_quiz Feature: Set a quiz with certainty-based marking As a teacher In order to set a a quiz with certainty-based marking I should set question behaviour to "Immediate feedback with CBM" Background: Given the following "users" exist: | username | firstname | lastname | email | | student1 | Student | One | student1@example.com | | teacher1 | Teacher | One | teacher1@example.com | And the following "courses" exist: | fullname | shortname | category | | Course 1 | C1 | 0 | And the following "course enrolments" exist: | user | course | role | | student1 | C1 | student | | 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 | | Test questions | truefalse | TF1 | First question | | Test questions | truefalse | TF2 | Second question | | Test questions | truefalse | TF3 | Third question | And quiz "Quiz 1" contains the following questions: | question | page | | TF1 | 1 | | TF2 | 2 | | TF3 | 3 | @javascript Scenario: Teacher can set a quiz with certainty-based marking Given I am on the "Quiz 1" "quiz activity editing" page logged in as teacher1 And I set the following fields to these values: | preferredbehaviour | immediatecbm | And I press "Save and return to course" And I am on the "Quiz 1" "quiz activity" page logged in as student1 When I press "Attempt quiz" # Press "Check" without selecting a certainty And I press "Check" # Confirm that "Please select a certainty." is displayed when "Check" is pressed Then I should see "Please select a certainty." And I click on "True" "radio" in the "First question" "question" And I click on "C=1 (Unsure: <67%)" "radio" in the "First question" "question" And I press "Next page" And I click on "False" "radio" in the "Second question" "question" And I click on "C=2 (Mid: >67%)" "radio" in the "Second question" "question" And I press "Next page" And I click on "True" "radio" in the "Third question" "question" And I click on "C=3 (Quite sure: >80%)" "radio" in the "Third question" "question" And I press "Finish attempt ..." And I press "Submit all and finish" And I click on "Submit all and finish" "button" in the "Submit all your answers and finish?" "dialogue" # As student1, confirm the results of own attempt And the following should exist in the "quizreviewsummary" table: | -1- | -2- | | Marks | 2.00/3.00 | | Grade | 66.67 out of 100.00 | | Average CBM mark | 0.67 | | Accuracy | 66.7% | | CBM bonus | 0.0% | | Accuracy + Bonus | 66.7% | | C=3 | Responses: 1. Accuracy: 100%. (Optimal range 80% to 100%). You were OK using this certainty level. | | C=2 | Responses: 1. Accuracy: 0%. (Optimal range 67% to 80%). You were a bit over-confident using this certainty level. | | C=1 | Responses: 1. Accuracy: 100%. (Optimal range 0% to 67%). You were a bit under-confident using this certainty level. | And I should see "CBM mark 1.00" in the "First question" "question" And I should see "CBM mark -2.00" in the "Second question" "question" And I should see "CBM mark 3.00" in the "Third question" "question" # As teacher, confirm same quiz contents And I am on the "Quiz 1" "quiz activity" page logged in as teacher1 And I press "Preview quiz" And I should see "C=1 (Unsure: <67%)" And I should see "C=2 (Mid: >67%)" And I should see "C=3 (Quite sure: >80%)" And I am on the "Quiz 1" "mod_quiz > Grades report" page And I click on "Review attempt" "link" in the "Student One" "table_row" # As teacher, confirm that the attempt result is same with student1 view And the following should exist in the "quizreviewsummary" table: | -1- | -2- | | Marks | 2.00/3.00 | | Grade | 66.67 out of 100.00 | | Average CBM mark | 0.67 | | Accuracy | 66.7% | | CBM bonus | 0.0% | | Accuracy + Bonus | 66.7% | | C=3 | Responses: 1. Accuracy: 100%. (Optimal range 80% to 100%). You were OK using this certainty level. | | C=2 | Responses: 1. Accuracy: 0%. (Optimal range 67% to 80%). You were a bit over-confident using this certainty level. | | C=1 | Responses: 1. Accuracy: 100%. (Optimal range 0% to 67%). You were a bit under-confident using this certainty level. | And I should see "CBM mark 1.00" in the "First question" "question" And I should see "CBM mark -2.00" in the "Second question" "question" And I should see "CBM mark 3.00" in the "Third question" "question" PK9B\n1tests/behat/editing_set_marks_no_attempts.featurenu[@mod @mod_quiz Feature: Edit quiz marks with no attempts In order to create a quiz that awards marks the way I want As a teacher I must be able to set the marks I want on the Edit quiz page. 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 | grade | decimalpoints | questiondecimalpoints | | quiz | Quiz 1 | C1 | quiz1 | 20 | 2 | -1 | 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 me | | Test questions | truefalse | Second question | Answer again | And quiz "Quiz 1" contains the following questions: | question | page | maxmark | | First question | 1 | 2.0 | | Second question | 1 | 3.0 | And I am on the "Quiz 1" "mod_quiz > Edit" page logged in as "teacher1" And I change window size to "large" @javascript Scenario: Set the max mark for a question. When I set the max mark for question "First question" to "7.0" Then I should see "7.00" And I should see "3.00" And I should see "Total of marks: 10.00" When I follow "Edit maximum mark" And I press the escape key Then I should see "7.00" And I should see "3.00" And I should see "Total of marks: 10.00" And "li input[name=maxmark]" "css_element" should not exist @javascript Scenario: Set the max mark for a question taking locale into account Given the following "language customisations" exist: | component | stringid | value | | core_langconfig | decsep | , | And I reload the page When I set the max mark for question "First question" to "7,5" Then I should see "7,50" And I should see "3,00" And I should see "Total of marks: 10,50" And I follow "Edit maximum mark" And I press the escape key And I should see "7,50" And I should see "3,00" And I should see "Total of marks: 10,50" And "li input[name=maxmark]" "css_element" should not exist @javascript Scenario: Set the overall Maximum grade. When I set the field "maxgrade" to "10.0" And I press "savechanges" Then the field "maxgrade" matches value "10.00" And I should see "2.00" And I should see "3.00" And I should see "Total of marks: 5.00" @javascript Scenario: Verify the number of decimal places shown is what the quiz settings say it should be. # Then the field "maxgrade" matches value "20.00" -- with exact match on decimal places. Then "//input[@name = 'maxgrade' and @value = '20.00']" "xpath_element" should exist And I should see "2.00" And I should see "3.00" And I should see "Total of marks: 5.00" And I should not see "2.000" And I should not see "3.000" And I should not see "Total of marks: 5.000" And I am on the "Quiz 1" "quiz activity editing" page When I set the following fields to these values: | Decimal places in grades | 3 | | Decimal places in marks for questions | 5 | And I press "Save and display" When I am on the "Quiz 1" "mod_quiz > Edit" page # Then the field "maxgrade" matches value "20.000" -- with exact match on decimal places. Then "//input[@name = 'maxgrade' and @value = '20.000']" "xpath_element" should exist And I should see "2.00000" And I should see "3.00000" And I should see "Total of marks: 5.000" And I should not see "2.000000" And I should not see "3.000000" And I should not see "Total of marks: 5.0000" PK9B\nr r +tests/behat/editing_multiple_grades.featurenu[@mod @mod_quiz Feature: Setup multiple grades for a quiz In order to assess multiple things in one quiz As a teacher I need to be able to create multiple quiz grade items. 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 "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 | | Test questions | description | Info | Some information | | Test questions | truefalse | Question A | This is question 01 | | Test questions | truefalse | Question B | This is question 02 | | Test questions | truefalse | Question C | This is question 03 | @javascript Scenario: Navigation to, and display of, grading setup Given the following "mod_quiz > grade items" exist: | quiz | name | | Quiz 1 | Intuition | | Quiz 1 | Intelligence | | Quiz 1 | Unused grade item | And quiz "Quiz 1" contains the following questions: | question | page | grade item | | Info | 1 | | | Question A | 1 | Intuition | | Question B | 1 | Intelligence | | Question C | 2 | Intuition | When I am on the "Quiz 1" "mod_quiz > multiple grades setup" page logged in as teacher Then I should see "Grade items" And "Delete" "icon" should not exist in the "Intuition" "table_row" And "Intuition" row "Total of marks" column of "mod_quiz-grade-item-list" table should contain "2.00" And "Delete" "icon" should not exist in the "Intelligence" "table_row" And "Intelligence" row "Total of marks" column of "mod_quiz-grade-item-list" table should contain "1.00" And "Delete" "icon" should exist in the "Unused grade item" "table_row" And "Unused grade item" row "Total of marks" column of "mod_quiz-grade-item-list" table should contain "-" And the field "Question A" matches value "Intuition" And "1" row "Marks" column of "mod_quiz-slot-list" table should contain "1.00" And the field "Question B" matches value "Intelligence" And "2" row "Marks" column of "mod_quiz-slot-list" table should contain "1.00" And the field "Question C" matches value "Intuition" And "3" row "Marks" column of "mod_quiz-slot-list" table should contain "1.00" And I should not see "Info" @javascript Scenario: A grade item can be created and renamed Given quiz "Quiz 1" contains the following questions: | question | page | | Question A | 1 | When I am on the "Quiz 1" "mod_quiz > multiple grades setup" page logged in as teacher And I should see "Create grade items within your quiz. Allocate questions or quiz sections to these grade items to break down grade results into different areas." And I press "Add grade item" Then "New grade item 1" "table_row" should exist And I press "Add grade item" Then "New grade item 2" "table_row" should exist And I click on "Edit" "link" in the "New grade item 1" "table_row" And I set the field "New name for grade item" to "Intelligence" And I press enter And I should not see "New grade item 1" And "Intelligence" "table_row" should exist @javascript Scenario: Editing the name of a grade item can be cancelled Given the following "mod_quiz > grade items" exist: | quiz | name | | Quiz 1 | Intuition | And quiz "Quiz 1" contains the following questions: | question | page | | Question A | 1 | When I am on the "Quiz 1" "mod_quiz > multiple grades setup" page logged in as teacher And I click on "Edit" "link" in the "Intuition" "table_row" And I set the field "New name for grade item" to "Intelligence" And I press the escape key And I should not see "Intelligence" And "Intuition" "table_row" should exist @javascript Scenario: Unused grade items can be deleted Given the following "mod_quiz > grade items" exist: | quiz | name | | Quiz 1 | Unused grade item | And quiz "Quiz 1" contains the following questions: | question | page | | Question A | 1 | When I am on the "Quiz 1" "mod_quiz > multiple grades setup" page logged in as teacher And I follow "Delete grade item Unused grade item" Then I should not see "Unused grade item" And I should see "Create grade items within your quiz. Allocate questions or quiz sections to these grade items to break down grade results into different areas." @javascript Scenario: Grade item for a slot can be changed Given the following "mod_quiz > grade items" exist: | quiz | name | | Quiz 1 | Intuition | And quiz "Quiz 1" contains the following questions: | question | page | | Question A | 1 | When I am on the "Quiz 1" "mod_quiz > multiple grades setup" page logged in as teacher And "Delete" "icon" should exist in the "Intuition" "table_row" And I set the field "Question A" to "Intuition" Then "Delete" "icon" should not exist in the "Intuition" "table_row" And the field "Question A" matches value "Intuition" And I set the field "Question A" to "[none]" And "Delete" "icon" should exist in the "Intuition" "table_row" And the field "Question A" matches value "[none]" @javascript Scenario: All setup can be reset Given the following "mod_quiz > grade items" exist: | quiz | name | | Quiz 1 | Intuition | | Quiz 1 | Intelligence | | Quiz 1 | Unused grade item | And quiz "Quiz 1" contains the following questions: | question | page | grade item | | Question A | 1 | Intuition | | Question B | 1 | Intelligence | | Question C | 2 | Intuition | When I am on the "Quiz 1" "mod_quiz > multiple grades setup" page logged in as teacher And I press "Reset setup" And I click on "Reset" "button" in the "Reset grade items setup?" "dialogue" Then I should see "Create grade items within your quiz. Allocate questions or quiz sections to these grade items to break down grade results into different areas." And the field "Question A" matches value "[none]" And the field "Question B" matches value "[none]" And the field "Question C" matches value "[none]" And I should not see "Reset grade items setup" @javascript Scenario: Reset all can be cancelled Given the following "mod_quiz > grade items" exist: | quiz | name | | Quiz 1 | Intuition | When I am on the "Quiz 1" "mod_quiz > multiple grades setup" page logged in as teacher And I press "Reset setup" And I click on "Cancel" "button" in the "Reset grade items setup?" "dialogue" Then I should see "Intuition" @javascript Scenario: Automatically set up one grade item per section Given quiz "Quiz 1" contains the following questions: | question | page | | Question A | 1 | | Question B | 1 | | Question C | 2 | And quiz "Quiz 1" contains the following sections: | heading | firstslot | shuffle | | Reading | 1 | 0 | | Listening | 3 | 0 | When I am on the "Quiz 1" "mod_quiz > multiple grades setup" page logged in as teacher And I press "Set up a grade for each section" Then "Reading" "table_row" should exist in the "mod_quiz-grade-item-list" "table" And "Listening" "table_row" should exist in the "mod_quiz-grade-item-list" "table" And the field "Question A" matches value "Reading" And the field "Question B" matches value "Reading" And the field "Question C" matches value "Listening" PK9B\  9tests/behat/completion_condition_minimum_attempts.featurenu[@mod @mod_quiz @core_completion @javascript Feature: Set a quiz to be marked complete when the student completes a minimum amount of attempts In order to ensure a student has completed the quiz before being marked complete As a teacher I need to set a quiz to complete when the student completes a certain amount of attempts Background: Given the following "users" exist: | username | firstname | lastname | email | | student1 | Student | 1 | student1@example.com | | teacher1 | Teacher | 1 | teacher1@example.com | And the following "courses" exist: | fullname | shortname | category | enablecompletion | | Course 1 | C1 | 0 | 1 | And the following "course enrolments" exist: | user | course | role | | teacher1 | C1 | editingteacher | | student1 | C1 | student | And the following "question categories" exist: | contextlevel | reference | name | | Course | C1 | Test questions | And the following "questions" exist: | questioncategory | qtype | name | questiontext | | Test questions | truefalse | First question | Answer the first question | And the following "activities" exist: | activity | name | course | idnumber | completion | completionminattemptsenabled | completionminattempts | | quiz | Test quiz name | C1 | quiz1 | 2 | 1 | 2 | And quiz "Test quiz name" contains the following questions: | question | page | | First question | 1 | And user "student1" has attempted "Test quiz name" with responses: | slot | response | | 1 | False | Scenario: student1 uses up both attempts without passing When I am on the "Course 1" course page logged in as teacher1 And "Completed: Test quiz name" "icon" should not exist in the "Test quiz name" "list_item" And I log out And I am on the "Course 1" course page logged in as student1 And the "Make attempts: 2" completion condition of "Test quiz name" is displayed as "todo" And I click on "Test quiz name" "link" in the "region-main" "region" And I press "Re-attempt quiz" And I set the field "False" to "1" And I press "Finish attempt ..." And I press "Submit all and finish" And I click on "Submit all and finish" "button" in the "Submit all your answers and finish?" "dialogue" And I am on "Course 1" course homepage Then the "Make attempts: 2" completion condition of "Test quiz name" is displayed as "done" And I click on "Test quiz name" "link" in the "region-main" "region" And the "Make attempts: 2" completion condition of "Test quiz name" is displayed as "done" And I log out And I am on the "Course 1" course page logged in as teacher1 And I click on "Test quiz name" "link" in the "region-main" "region" And "Test quiz name" should have the "Make attempts: 2" completion condition And I am on "Course 1" course homepage And I navigate to "Reports" in current page administration And I click on "Activity completion" "link" And "Completed" "icon" should exist in the "Student 1" "table_row" PK9B\Kqq2tests/behat/settings_form_fields_disableif.featurenu[@mod @mod_quiz Feature: Settings form fields disabled if not required In to create quizzes as simply as possible As a teacher I don't need to to use certain form fields. Background: Given the following "users" exist: | username | firstname | | teacher | Teach | | student1 | Student1 | | student2 | Student2 | And the following "courses" exist: | fullname | shortname | category | | Course 1 | C1 | 0 | And the following "course enrolments" exist: | user | course | role | | teacher | C1 | editingteacher | | student1 | C1 | student | | student2 | C1 | student | And the following "activities" exist: | activity | course | section | name | | quiz | C1 | 1 | Test quiz 1 | @javascript Scenario: Depending on the number of attempts, different form fields are disabled. When I am on the "Test quiz 1" "quiz activity editing" page logged in as teacher And I expand all fieldsets And I set the field "Attempts allowed" to "1" Then the "Grading method" "field" should be disabled And the "Each attempt builds on the last" "field" should be disabled And the "id_delay1_enabled" "field" should be disabled And the "id_delay2_enabled" "field" should be disabled And I set the field "Attempts allowed" to "2" And the "Grading method" "field" should be enabled And the "Each attempt builds on the last" "field" should be enabled And the "id_delay1_enabled" "field" should be enabled And the "id_delay2_enabled" "field" should be disabled And I set the field "Attempts allowed" to "3" And the "Grading method" "field" should be enabled And the "Each attempt builds on the last" "field" should be enabled And the "id_delay1_enabled" "field" should be enabled And the "id_delay2_enabled" "field" should be enabled And I set the field "Attempts allowed" to "Unlimited" And the "Grading method" "field" should be enabled And the "Each attempt builds on the last" "field" should be enabled # And the "id_delay1_enabled" "field" should be enabled # And the "id_delay2_enabled" "field" should be enabled And I press "Save and display" And I navigate to "Overrides" in current page administration And I press "Add user override" And I set the following fields to these values: | Override user | Student1 | | Attempts allowed | 3 | And I press "Save" And I navigate to "Settings" in current page administration And I expand all fieldsets And I set the field "Attempts allowed" to "1" And the "Grading method" "field" should be enabled And the "Each attempt builds on the last" "field" should be enabled And the "id_delay1_enabled" "field" should be enabled And the "id_delay2_enabled" "field" should be enabled And I press "Save and display" And I navigate to "Overrides" in current page administration And I click on "Edit" "link" in the "region-main" "region" And I set the field "Attempts allowed" to "2" And I press "Save" And I navigate to "Settings" in current page administration And I expand all fieldsets And I set the field "Attempts allowed" to "1" And the "Grading method" "field" should be enabled And the "Each attempt builds on the last" "field" should be enabled And the "id_delay1_enabled" "field" should be enabled And the "id_delay2_enabled" "field" should be disabled And I press "Save and display" And I navigate to "Overrides" in current page administration And I press "Add user override" And I set the following fields to these values: | Override user | Student2 | | Attempts allowed | Unlimited | And I press "Save" And I navigate to "Settings" in current page administration And I expand all fieldsets And I set the field "Attempts allowed" to "1" And the "Grading method" "field" should be enabled And the "Each attempt builds on the last" "field" should be enabled And the "id_delay1_enabled" "field" should be enabled And the "id_delay2_enabled" "field" should be enabled @javascript Scenario: Depending on whether there is a close date, some review options are disabled. When I log in as "teacher" And I add a quiz activity to course "Course 1" section "1" And I expand all fieldsets And I set the field "Name" to "Test quiz" Then the "id_attemptclosed" "checkbox" should be disabled And the "id_correctnessclosed" "checkbox" should be disabled And the "id_maxmarksclosed" "checkbox" should be disabled And the "id_marksclosed" "checkbox" should be disabled And the "id_specificfeedbackclosed" "checkbox" should be disabled And the "id_generalfeedbackclosed" "checkbox" should be disabled And the "id_rightanswerclosed" "checkbox" should be disabled And the "id_overallfeedbackclosed" "checkbox" should be disabled And I set the field "id_timeclose_enabled" to "1" And the "id_attemptclosed" "checkbox" should be enabled And the "id_correctnessclosed" "checkbox" should be enabled And the "id_maxmarksclosed" "checkbox" should be enabled And the "id_marksclosed" "checkbox" should be enabled And I set the field "id_maxmarksclosed" to "0" And the "id_marksclosed" "checkbox" should be disabled And the "id_specificfeedbackclosed" "checkbox" should be enabled And the "id_generalfeedbackclosed" "checkbox" should be enabled And the "id_rightanswerclosed" "checkbox" should be enabled And the "id_overallfeedbackclosed" "checkbox" should be enabled And I set the field "id_maxmarksduring" to "0" And the "id_marksduring" "checkbox" should be disabled And I should not see "Repaginate now" @javascript Scenario: If there are quiz attempts, there is not option to repaginate. Given the following "activities" exist: | activity | name | intro | course | idnumber | | quiz | Quiz 1 | Quiz 1 description | C1 | quiz1 | | qbank | Qbank 1 | Qbank for testing | 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 | TF1 | First question | | Test questions | truefalse | TF2 | Second question | And quiz "Quiz 1" contains the following questions: | question | page | maxmark | | TF1 | 1 | | When I am on the "Quiz 1" "quiz activity editing" page logged in as teacher And I expand all fieldsets Then I should see "Repaginate now" And user "student1" has attempted "Quiz 1" with responses: | slot | response | | 1 | True | And I am on the "Quiz 1" "quiz activity editing" page And I expand all fieldsets And I should not see "Repaginate now" @javascript Scenario Outline: Pre-create attempts setting is only shown if precreateperiod is set and timeopen is enabled. Given the following config values are set as admin: | precreateattempts | 1 | quiz | | precreateattempts_locked | 0 | quiz | | precreateperiod | | quiz | And I log in as "admin" When I add a "quiz" activity to course "Course 1" section 1 And I click on "Timing" "link" And I set the field "timeopen[enabled]" to "" And I expand all fieldsets Then I see "Pre-create attempts" And "Pre-create attempts" "select" be visible And I see "Yes, 1 hours before quiz open time" Examples: | period | timeopen | exists | | 3600 | 1 | should | | 3600 | 0 | should not | | 0 | 1 | should not | PK9B\Әgy5y5,tests/behat/editing_require_previous.featurenu[@mod @mod_quiz Feature: Edit quizzes where some questions require the previous one to have been completed In order to create quizzes where later questions can only be seen after earlier ones are answered As a teacher I need to be able to configure this on the Edit quiz page 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 | | qbank | Qbank 1 | C1 | qbank1 | And the following "question categories" exist: | contextlevel | reference | name | | Activity module | qbank1 | Test questions | And I log in as "teacher1" @javascript Scenario: The first question cannot depend on the previous (whatever is in the DB) Given the following "activities" exist: | activity | name | intro | course | idnumber | preferredbehaviour | | quiz | Quiz 1 | Quiz 1 description | C1 | quiz1 | immediatefeedback | And the following "questions" exist: | questioncategory | qtype | name | questiontext | | Test questions | truefalse | TF1 | First question | And quiz "Quiz 1" contains the following questions: | question | page | requireprevious | | TF1 | 1 | 1 | And I am on the "Quiz 1" "mod_quiz > Edit" page Then "be attempted" "link" should not be visible # The text "be attempted" is used as a relatively unique string in both the add and remove links. @javascript Scenario: If the second question depends on the first, that is shown Given the following "activities" exist: | activity | name | intro | course | idnumber | preferredbehaviour | | quiz | Quiz 1 | Quiz 1 description | C1 | quiz1 | immediatefeedback | And the following "questions" exist: | questioncategory | qtype | name | questiontext | | Test questions | truefalse | TF1 | First question | | Test questions | truefalse | TF2 | Second question | And quiz "Quiz 1" contains the following questions: | question | page | requireprevious | | TF1 | 1 | 0 | | TF2 | 1 | 1 | And I am on the "Quiz 1" "mod_quiz > Edit" page Then "This question cannot be attempted until the previous question has been completed." "link" should be visible @javascript Scenario: A question can depend on a random question Given the following "activities" exist: | activity | name | intro | course | idnumber | preferredbehaviour | | quiz | Quiz 1 | Quiz 1 description | C1 | quiz1 | immediatefeedback | And the following "questions" exist: | questioncategory | qtype | name | questiontext | | Test questions | truefalse | TF1 | First question | | Test questions | random | Random (Test questions) | 0 | And quiz "Quiz 1" contains the following questions: | question | page | requireprevious | | Random (Test questions) | 1 | 0 | | TF1 | 1 | 1 | And I am on the "Quiz 1" "mod_quiz > Edit" page Then "This question cannot be attempted until the previous question has been completed." "link" should be visible @javascript Scenario: The second question can be set to depend on the first Given the following "activities" exist: | activity | name | intro | course | idnumber | preferredbehaviour | | quiz | Quiz 1 | Quiz 1 description | C1 | quiz1 | immediatefeedback | And the following "questions" exist: | questioncategory | qtype | name | questiontext | | Test questions | truefalse | TF1 | First question | | Test questions | truefalse | TF2 | Second question | | Test questions | truefalse | TF3 | Third question | And quiz "Quiz 1" contains the following questions: | question | page | requireprevious | | TF1 | 1 | 0 | | TF2 | 1 | 0 | | TF3 | 1 | 0 | And I am on the "Quiz 1" "mod_quiz > Edit" page When I follow "No restriction on when question 2 can be attempted • Click to change" Then "Question 2 cannot be attempted until the previous question 1 has been completed • Click to change" "link" should be visible And "No restriction on when question 3 can be attempted • Click to change" "link" should be visible @javascript Scenario: A question that did depend on the previous can be un-linked Given the following "activities" exist: | activity | name | intro | course | idnumber | preferredbehaviour | | quiz | Quiz 1 | Quiz 1 description | C1 | quiz1 | immediatefeedback | And the following "questions" exist: | questioncategory | qtype | name | questiontext | | Test questions | truefalse | TF1 | First question | | Test questions | truefalse | TF2 | Second question | | Test questions | truefalse | TF3 | Third question | And quiz "Quiz 1" contains the following questions: | question | page | requireprevious | | TF1 | 1 | 0 | | TF2 | 1 | 1 | | TF3 | 1 | 1 | And I am on the "Quiz 1" "mod_quiz > Edit" page When I follow "Question 3 cannot be attempted until the previous question 2 has been completed • Click to change" Then "Question 2 cannot be attempted until the previous question 1 has been completed • Click to change" "link" should be visible And "No restriction on when question 3 can be attempted • Click to change" "link" should be visible @javascript Scenario: Question dependency cannot apply to deferred feedback quizzes so UI is hidden Given the following "activities" exist: | activity | name | intro | course | idnumber | preferredbehaviour | | quiz | Quiz 1 | Quiz 1 description | C1 | quiz1 | deferredfeedback | And the following "questions" exist: | questioncategory | qtype | name | questiontext | | Test questions | truefalse | TF1 | First question | | Test questions | truefalse | TF2 | Second question | | Test questions | random | Random (Test questions) | 0 | And quiz "Quiz 1" contains the following questions: | question | page | requireprevious | | Random (Test questions) | 1 | 0 | | TF1 | 1 | 1 | | TF2 | 1 | 1 | And I am on the "Quiz 1" "mod_quiz > Edit" page Then "be attempted" "link" in the "TF1" "list_item" should not be visible Then "be attempted" "link" in the "TF2" "list_item" should not be visible @javascript Scenario: Question dependency cannot apply to questions in a shuffled section so UI is hidden Given the following "activities" exist: | activity | name | intro | course | idnumber | preferredbehaviour | questionsperpage | | quiz | Quiz 1 | Quiz 1 description | C1 | quiz1 | immediatefeedback | 2 | And the following "questions" exist: | questioncategory | qtype | name | questiontext | | Test questions | truefalse | TF1 | First question | | Test questions | truefalse | TF2 | Second question | And quiz "Quiz 1" contains the following questions: | question | page | requireprevious | | TF1 | 1 | 1 | | TF2 | 1 | 1 | And quiz "Quiz 1" contains the following sections: | heading | firstslot | shuffle | | Section 1 | 1 | 1 | And I am on the "Quiz 1" "mod_quiz > Edit" page Then "be attempted" "link" in the "TF2" "list_item" should not be visible @javascript Scenario: Question dependency cannot apply to the first questions in section when the previous section is shuffled Given the following "activities" exist: | activity | name | intro | course | idnumber | preferredbehaviour | questionsperpage | | quiz | Quiz 1 | Quiz 1 description | C1 | quiz1 | immediatefeedback | 2 | And the following "questions" exist: | questioncategory | qtype | name | questiontext | | Test questions | truefalse | TF1 | First question | | Test questions | truefalse | TF2 | Second question | And quiz "Quiz 1" contains the following questions: | question | page | requireprevious | | TF1 | 1 | 1 | | TF2 | 1 | 1 | And quiz "Quiz 1" contains the following sections: | heading | firstslot | shuffle | | Section 1 | 1 | 1 | | Section 2 | 2 | 0 | And I am on the "Quiz 1" "mod_quiz > Edit" page Then "be attempted" "link" in the "TF2" "list_item" should not be visible @javascript Scenario: Question dependency cannot apply to quizzes with sequential navigation so UI is hidden Given the following "activities" exist: | activity | name | intro | course | idnumber | preferredbehaviour | navmethod | | quiz | Quiz 1 | Quiz 1 description | C1 | quiz1 | immediatefeedback | sequential | And the following "questions" exist: | questioncategory | qtype | name | questiontext | | Test questions | truefalse | TF1 | First question | | Test questions | truefalse | TF2 | Second question | And quiz "Quiz 1" contains the following questions: | question | page | requireprevious | | TF1 | 1 | 1 | | TF2 | 1 | 1 | And I am on the "Quiz 1" "mod_quiz > Edit" page Then "be attempted" "link" in the "TF2" "list_item" should not be visible @javascript Scenario: A question can never depend on an essay Given the following "activities" exist: | activity | name | intro | course | idnumber | preferredbehaviour | | quiz | Quiz 1 | Quiz 1 description | C1 | quiz1 | immediatefeedback | And the following "questions" exist: | questioncategory | qtype | name | questiontext | | Test questions | essay | Story | First question | | Test questions | truefalse | TF1 | First question | And quiz "Quiz 1" contains the following questions: | question | page | requireprevious | | Story | 1 | 0 | | TF1 | 1 | 0 | And I am on the "Quiz 1" "mod_quiz > Edit" page Then "be attempted" "link" in the "TF1" "list_item" should not be visible @javascript Scenario: A question can never depend on a description Given the following "activities" exist: | activity | name | intro | course | idnumber | preferredbehaviour | | quiz | Quiz 1 | Quiz 1 description | C1 | quiz1 | immediatefeedback | And the following "questions" exist: | questioncategory | qtype | name | questiontext | | Test questions | description | Info | Read me | | Test questions | truefalse | TF1 | First question | And quiz "Quiz 1" contains the following questions: | question | page | requireprevious | | Info | 1 | 0 | | TF1 | 1 | 0 | And I am on the "Quiz 1" "mod_quiz > Edit" page Then "be attempted" "link" in the "TF1" "list_item" should not be visible @javascript Scenario: When questions are reordered, the dependency icons are updated correctly Given the following "activities" exist: | activity | name | intro | course | idnumber | preferredbehaviour | | quiz | Quiz 1 | Quiz 1 description | C1 | quiz1 | immediatefeedback | And the following "questions" exist: | questioncategory | qtype | name | questiontext | | Test questions | truefalse | TF1 | First question | | Test questions | truefalse | TF2 | Second question | | Test questions | truefalse | TF3 | Third question | And quiz "Quiz 1" contains the following questions: | question | page | requireprevious | | TF1 | 1 | 0 | | TF2 | 1 | 1 | | TF3 | 1 | 1 | And I am on the "Quiz 1" "mod_quiz > Edit" page When I move "TF1" to "After Question 3" in the quiz by clicking the move icon Then "Question 2 cannot be attempted until the previous question 1 has been completed • Click to change" "link" should be visible And "No restriction on when question 3 can be attempted • Click to change" "link" should be visible And "be attempted" "link" in the "TF2" "list_item" should not be visible PK9B\ξ  tests/behat/info_page.featurenu[@mod @mod_quiz Feature: Display of information before starting a quiz As a student In order to start a quiz with confidence I need information about the quiz settings before I start an attempt Background: Given the following "users" exist: | username | firstname | lastname | email | | student | Student | One | student@example.com | And the following "courses" exist: | fullname | shortname | category | | Course 1 | C1 | 0 | And the following "course enrolments" exist: | user | course | role | | student | C1 | student | 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 | questiontext | | Test questions | truefalse | TF1 | Text of the first question | Scenario: Check the pass grade is displayed Given the following "activities" exist: | activity | name | intro | course | idnumber | gradepass | | quiz | Quiz 1 | Quiz 1 description | C1 | quiz1 | 60.00 | And quiz "Quiz 1" contains the following questions: | question | page | | TF1 | 1 | When I am on the "Quiz 1" "mod_quiz > View" page logged in as "student" Then I should see "Grade to pass: 60.00 out of 100.00" Scenario: Check the pass grade is displayed with custom decimal separator Given the following "language customisations" exist: | component | stringid | value | | core_langconfig | decsep | # | And the following "activities" exist: | activity | name | intro | course | idnumber | gradepass | | quiz | Quiz 1 | Quiz 1 description | C1 | quiz1 | 60#00 | And quiz "Quiz 1" contains the following questions: | question | page | | TF1 | 1 | When I am on the "Quiz 1" "mod_quiz > View" page logged in as "student" Then I should see "Grade to pass: 60#00 out of 100#00" Scenario: Check the pass grade is not displayed if not set Given the following "activities" exist: | activity | name | intro | course | idnumber | gradepass | | quiz | Quiz 1 | Quiz 1 description | C1 | quiz1 | | And quiz "Quiz 1" contains the following questions: | question | page | | TF1 | 1 | When I am on the "Quiz 1" "mod_quiz > View" page logged in as "student" Then I should not see "Grade to pass: 0.00" PK9B\Q6//&tests/behat/quiz_user_override.featurenu[@mod @mod_quiz Feature: Quiz user override In order to grant a student special access to a quiz As a teacher I need to create an override for that user. Background: And the following "custom profile fields" exist: | datatype | shortname | name | | text | frog | Favourite frog | Given the following "users" exist: | username | firstname | lastname | email | profile_field_frog | | teacher | Teacher | One | teacher@example.com | | | helper | Exam | Helper | helper@example.com | | | student1 | Student | One | student1@example.com | yellow frog | | student2 | Student | Two | student2@example.com | prince frog | | student3 | Student | Three | student3@example.com | Kermit | And the following "courses" exist: | fullname | shortname | category | | Course 1 | C1 | 0 | And the following "course enrolments" exist: | user | course | role | | teacher | C1 | editingteacher | | helper | C1 | teacher | | student1 | C1 | student | | student2 | C1 | student | | student3 | C1 | student | @javascript Scenario: Add, modify then delete a user override Given the following "activities" exist: | activity | name | course | idnumber | | quiz | Test quiz | C1 | quiz1 | And I am on the "Test quiz" "mod_quiz > View" page logged in as "teacher" And I change window size to "large" And I navigate to "Overrides" in current page administration And I press "Add user override" And I set the following fields to these values: | Override user | Student One (student1@example.com) | | id_timeclose_enabled | 1 | | timeclose[day] | 1 | | timeclose[month] | January | | timeclose[year] | 2020 | | timeclose[hour] | 08 | | timeclose[minute] | 00 | And I press "Save" Then I should see "Wednesday, 1 January 2020, 8:00" And I click on "Edit" "link" in the "Student One" "table_row" And I set the following fields to these values: | timeclose[year] | 2030 | And I press "Save" And I should see "Action" And I should see "Tuesday, 1 January 2030, 8:00" in the "Student One" "table_row" And I should see "student1@example.com" in the "Student One" "table_row" And I click on "Delete" "link" in the "Student One" "table_row" And I should see "Are you sure you want to delete the override for user Student One (student1@example.com)?" And I press "Continue" And I should not see "Student One" @javascript Scenario: Add multiple user overrides, one after another Given the following "activities" exist: | activity | name | course | idnumber | | quiz | Test quiz | C1 | quiz1 | And I am on the "Test quiz" "mod_quiz > View" page logged in as "teacher" And I change window size to "large" And I navigate to "Overrides" in current page administration And I press "Add user override" And I set the following fields to these values: | Override user | Student One | | timeclose[enabled] | 1 | | Close the quiz | ## 1 January 2020 08:00 ## | And I press "Save and enter another override" And I set the following fields to these values: | Override user | Student Two | | timeclose[enabled] | 1 | | Close the quiz | ## 2 January 2020 08:00 ## | When I press "Save" Then the following should exist in the "generaltable" table: | User | Overrides | -4- | | Student One | Quiz closes | 1 January 2020, 8:00 | | Student Two | Quiz closes | 2 January 2020, 8:00 | @javascript Scenario: Can add a user override when the quiz is not available to the student Given the following "activities" exist: | activity | name | course | idnumber | visible | | quiz | Test quiz | C1 | quiz1 | 0 | When I am on the "Test quiz" "mod_quiz > User overrides" page logged in as "teacher" And I press "Add user override" And I set the following fields to these values: | Override user | Student One (student1@example.com) | | Attempts allowed | 1 | And I press "Save" Then I should see "This override is inactive" And I should see "Action" And "Edit" "icon" should exist in the "Student One" "table_row" And "copy" "icon" should exist in the "Student One" "table_row" And "Delete" "icon" should exist in the "Student One" "table_row" And I follow "Student One" And I should see "Student One" And I should see "User details" @javascript Scenario: Teacher without 'See full user identity in lists' can see and edit overrides Given the following "permission overrides" exist: | capability | permission | role | contextlevel | reference | | moodle/site:viewuseridentity | Prevent | editingteacher | Course | C1 | And the following "activities" exist: | activity | name | course | idnumber | visible | | quiz | Test quiz | C1 | quiz1 | 0 | When I am on the "Test quiz" "mod_quiz > User overrides" page logged in as "teacher" And I press "Add user override" And I set the following fields to these values: | Override user | Student One | | Attempts allowed | 1 | And I press "Save" And I should see "Action" And I should not see "student1@example.com" And "Edit" "icon" should exist in the "Student One" "table_row" And "copy" "icon" should exist in the "Student One" "table_row" And "Delete" "icon" should exist in the "Student One" "table_row" Scenario: A teacher without accessallgroups permission should only be able to add user override for users that he/she shares groups with, when the activity's group mode is to "separate groups" Given the following "groups" exist: | name | course | idnumber | | Group 1 | C1 | G1 | | Group 2 | C1 | G2 | And the following "group members" exist: | user | group | | student1 | G1 | | teacher | G1 | | student2 | G2 | And the following "permission overrides" exist: | capability | permission | role | contextlevel | reference | | moodle/site:accessallgroups | Prevent | editingteacher | Course | C1 | And the following "activities" exist: | activity | name | course | idnumber | groupmode | | quiz | Test quiz | C1 | quiz1 | 1 | When I am on the "Test quiz" "mod_quiz > User overrides" page logged in as "teacher" And I press "Add user override" Then the "Override user" select box should contain "Student One (student1@example.com)" And the "Override user" select box should not contain "Student Two (student2@example.com)" Scenario: Override user in an activity with group mode set to "separate groups" as a teacher who is not a member in any group, and does not have accessallgroups permission Given the following "groups" exist: | name | course | idnumber | | Group 1 | C1 | G1 | And the following "group members" exist: | user | group | | student1 | G1 | And the following "permission overrides" exist: | capability | permission | role | contextlevel | reference | | moodle/site:accessallgroups | Prevent | editingteacher | Course | C1 | And the following "activities" exist: | activity | name | course | idnumber | groupmode | | quiz | Test quiz | C1 | quiz1 | 1 | When I am on the "Test quiz" "mod_quiz > User overrides" page logged in as "teacher" Then I should see "There are no groups in this course." And the "Add user override" "button" should be disabled Scenario: A non-editing teacher can see the overrides, but not change them Given the following "activities" exist: | activity | name | course | idnumber | | quiz | Test quiz | C1 | quiz1 | And the following "mod_quiz > user overrides" exist: | quiz | user | attempts | | Test quiz | student1 | 2 | | Test quiz | student2 | 2 | And I am on the "Test quiz" "mod_quiz > View" page logged in as "helper" When I navigate to "Overrides" in current page administration Then "Student One" "table_row" should exist And "Student Two" "table_row" should exist And "Add user override" "button" should not exist And I should not see "Action" And "Edit" "link" should not exist in the "Student One" "table_row" And "Copy" "link" should not exist in the "Student One" "table_row" And "Delete" "link" should not exist in the "Student One" "table_row" And I am on the "Test quiz" "mod_quiz > View" page And I should see "Settings overrides exist (Users: 2)" @javascript Scenario: Teachers can see user additional user identity information Given the following config values are set as admin: | showuseridentity | email,profile_field_frog | And the following "activities" exist: | activity | name | course | idnumber | | quiz | Test quiz | C1 | quiz1 | And the following "mod_quiz > user overrides" exist: | quiz | user | attempts | | Test quiz | student1 | 2 | | Test quiz | student2 | 2 | When I am on the "Test quiz" "mod_quiz > User overrides" page logged in as "teacher" Then I should see "yellow frog" in the "Student One" "table_row" And I should see "prince frog" in the "Student Two" "table_row" And I press "Add user override" And I expand the "Override user" autocomplete And I should see "Kermit" And I should not see "Student one" And I should not see "Student two" And I press "Cancel" And I click on "Edit" "link" in the "Student One" "table_row" And I should see "Student One (student1@example.com, yellow frog)" And I press "Cancel" And I click on "Delete" "link" in the "Student One" "table_row" And I should see "Student One (student1@example.com, yellow frog)" Scenario: Add button disabled if no users Given the following "courses" exist: | fullname | shortname | category | | Course 2 | C2 | 0 | And the following "activities" exist: | activity | name | course | idnumber | | quiz | Other quiz | C2 | quiz2 | When I am on the "Other quiz" "mod_quiz > User overrides" page logged in as "admin" Then the "Add user override" "button" should be disabled @javascript Scenario: Should see only enrolled users in user selector Given the following "users" exist: | username | firstname | lastname | email | | manager | Max | Manager | man@example.com | And the following "role assigns" exist: | user | role | contextlevel | reference | | manager | manager | System | | And the following "activities" exist: | activity | name | course | idnumber | groupmode | | quiz | Test quiz | C1 | quiz1 | 1 | And the following "role capability" exists: | role | manager | | mod/quiz:attempt | allow | When I am on the "Test quiz" "mod_quiz > User overrides" page logged in as "teacher" And I press "Add user override" And I click on "Override user" "field" And I type "Max Manager" Then I should see "No suggestions" PK9B\=~+~+'tests/behat/quiz_group_override.featurenu[@mod @mod_quiz Feature: Quiz group override In order to grant a group special access to a quiz As a teacher I need to create an override for that group. Background: Given the following "users" exist: | username | firstname | lastname | email | | teacher1 | Terry 1 | Teacher 1 | teacher1@example.com | | student1 | Sam 1 | Student 1 | student1@example.com | | teacher2 | Terry 2 | Teacher 2 | teacher2@example.com | | student2 | Sam 2 | Student 2 | student2@example.com | | teacher3 | Terry 3 | Teacher 3 | teacher3@example.com | | student3 | Sam 3 | Student 3 | student3@example.com | | helper | Exam | Helper | helper@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 | | teacher2 | C1 | editingteacher | | student2 | C1 | student | | teacher3 | C1 | editingteacher | | student3 | C1 | student | | helper | C1 | teacher | And the following "groups" exist: | name | course | idnumber | | Group 1 | C1 | G1 | | Group 2 | C1 | G2 | | Group 3 | C1 | G3 | And the following "group members" exist: | user | group | | student1 | G1 | | teacher1 | G1 | | teacher1 | G3 | | student2 | G2 | | teacher2 | G2 | | teacher2 | G3 | | student3 | G3 | | helper | G1 | | helper | G2 | | helper | G3 | And the following "activities" exist: | activity | name | intro | course | idnumber | groupmode | | quiz | Test quiz | Test quiz description | C1 | quiz1 | 1 | Scenario: Override Group 1 as teacher of Group 1 Given the following "permission overrides" exist: | capability | permission | role | contextlevel | reference | | moodle/site:accessallgroups | Prevent | editingteacher | Course | C1 | When I am on the "Test quiz" "mod_quiz > Group overrides" page logged in as "teacher1" And I press "Add group override" Then the "Override group" select box should contain "Group 1" And the "Override group" select box should not contain "Group 2" Scenario: Add button disabled if there are no groups Given the following "permission overrides" exist: | capability | permission | role | contextlevel | reference | | moodle/site:accessallgroups | Prevent | editingteacher | Course | C1 | When I am on the "Test quiz" "mod_quiz > Group overrides" page logged in as "teacher3" Then I should see "There are no groups in this course." And the "Add group override" "button" should be disabled Scenario: A teacher can create an override When I am on the "Test quiz" "mod_quiz > Group overrides" page logged in as "teacher1" And I press "Add group override" And I set the following fields to these values: | Override group | Group 1 | | Attempts allowed | 2 | And I press "Save and enter another override" And I set the following fields to these values: | Override group | Group 3 | | Attempts allowed | 2 | And I press "Save" Then "Group 1" "table_row" should exist # Check all column headers are present. And I should see "Group" in the "Overrides" "table_row" And I should see "Action" in the "Overrides" "table_row" Scenario: A teacher with accessallgroups permission should see all group overrides Given the following "mod_quiz > group overrides" exist: | quiz | group | attempts | | Test quiz | G1 | 2 | | Test quiz | G2 | 2 | When I am on the "Test quiz" "mod_quiz > View" page logged in as "teacher1" Then I should see "Settings overrides exist (Groups: 2)" And I follow "Groups: 2" And "Group 1" "table_row" should exist And "Group 2" "table_row" should exist Scenario: A teacher without accessallgroups permission should only see the group overrides within his/her groups, when the activity's group mode is "separate groups" Given the following "permission overrides" exist: | capability | permission | role | contextlevel | reference | | moodle/site:accessallgroups | Prevent | editingteacher | Course | C1 | And the following "mod_quiz > group overrides" exist: | quiz | group | attempts | | Test quiz | G1 | 2 | | Test quiz | G2 | 2 | When I am on the "Test quiz" "mod_quiz > View" page logged in as "teacher1" Then I should see "Settings overrides exist (Groups: 1) for your groups" And I follow "Groups: 1" Then "Group 1" "table_row" should exist And "Group 2" "table_row" should not exist Scenario: A non-editing teacher can see the overrides, but not change them Given the following "mod_quiz > group overrides" exist: | quiz | group | attempts | | Test quiz | G1 | 2 | | Test quiz | G2 | 2 | When I am on the "Test quiz" "mod_quiz > Group overrides" page logged in as "helper" Then "Group 1" "table_row" should exist And "Group 2" "table_row" should exist And "Add group override" "button" should not exist And "Edit" "link" should not exist in the "Group 1" "table_row" And "Copy" "link" should not exist in the "Group 1" "table_row" And "Delete" "link" should not exist in the "Group 1" "table_row" Scenario: "Not visible" groups should not be available for group overrides Given the following "groups" exist: | name | course | idnumber | visibility | participation | | Visible to everyone/Participation | C1 | VP | 0 | 1 | | Only visible to members/Participation | C1 | MP | 1 | 1 | | Only see own membership | C1 | O | 2 | 0 | | Not visible | C1 | N | 3 | 0 | | Visible to everyone/Non-Participation | C1 | VN | 0 | 0 | | Only visible to members/Non-Participation | C1 | MN | 1 | 0 | When I am on the "quiz1" Activity page logged in as teacher1 And I navigate to "Overrides" in current page administration And I select "Group overrides" from the "jump" singleselect And I press "Add group override" Then I should see "Visible to everyone/Participation" in the "Override group" "select" And I should see "Visible to everyone/Non-Participation" in the "Override group" "select" And I should see "Only visible to members" in the "Override group" "select" And I should see "Only visible to members/Non-Participation" in the "Override group" "select" And I should see "Only see own membership" in the "Override group" "select" And I should not see "Not visible" in the "Override group" "select" @javascript Scenario: Quiz activity group overrides are displayed on the timeline block Given the following "group members" exist: | user | group | | student1 | G2 | And I am on the "Test quiz" "quiz activity editing" page logged in as teacher1 And I set the following fields to these values: | timeopen[enabled] | 1 | | timeclose[enabled] | 1 | | timeopen | ##today## | | timeclose | ##tomorrow## | And I press "Save and display" When I log in as "student1" Then I should see "##tomorrow##%A, %d %B %Y##" in the "Timeline" "block" And the following "mod_quiz > group overrides" exist: | quiz | group | timeopen | timeclose | | Test quiz | G1 | ##tomorrow## | ##tomorrow +1day## | And I reload the page And I should see "##tomorrow +1day##%A, %d %B %Y##" in the "Timeline" "block" And the following "mod_quiz > group overrides" exist: | quiz | group | timeopen | timeclose | | Test quiz | G2 | ##tomorrow +1day## | ##tomorrow +3days## | And I reload the page And I should see "##tomorrow +3days##%A, %d %B %Y##" in the "Timeline" "block" @javascript Scenario: Quiz activity user override is displayed even if group override exists on the timeline block Given the following "group members" exist: | user | group | | student1 | G2 | And I am on the "Test quiz" "quiz activity editing" page logged in as teacher1 And I set the following fields to these values: | timeopen[enabled] | 1 | | timeclose[enabled] | 1 | | timeopen | ##today## | | timeclose | ##tomorrow## | And I press "Save and display" And the following "mod_quiz > group overrides" exist: | quiz | group | timeopen | timeclose | | Test quiz | G1 | ##tomorrow## | ##tomorrow +1day## | | Test quiz | G2 | ##tomorrow +1day## | ##tomorrow +3days## | And I navigate to "Overrides" in current page administration And I press "Add user override" And I set the following fields to these values: | Override user | Sam 1 Student 1 | | timeopen | ##tomorrow## | | timeclose | ##tomorrow noon## | And I press "Save" When I log in as "student1" Then I should see "##tomorrow noon##%A, %d %B %Y##" in the "Timeline" "block" @javascript Scenario: Quiz activity override are not visible on timeline block when student is unenrolled Given the following "group members" exist: | user | group | | student1 | G2 | And the following "mod_quiz > group overrides" exist: | quiz | group | timeopen | timeclose | | Test quiz | G2 | ##tomorrow +1day## | ##tomorrow +3days## | And I am on the "Test quiz" "quiz activity" page logged in as teacher1 And I navigate to "Overrides" in current page administration And I press "Add user override" And I set the following fields to these values: | Override user | Sam 1 Student 1 | | timeopen | ##tomorrow## | | timeclose | ##tomorrow noon## | And I am on "Course 1" course homepage And I navigate to course participants And I click on "Unenrol" "icon" in the "student1" "table_row" And I click on "Unenrol" "button" in the "Unenrol" "dialogue" When I log in as "student1" Then "Test quiz" "link" should not exist in the "Timeline" "block" And I should not see "##tomorrow noon##%A, %d %B %Y##" in the "Timeline" "block" PK9B\ն]5 5 'tests/behat/editing_switch_bank.featurenu[@mod @mod_qbank @javascript Feature: Switching question bank when adding questions to a quiz In order to re-use questions As a teacher I want to be able to switch to other banks I have access to. Background: Given the following "users" exist: | username | | teacher | And the following "courses" exist: | fullname | shortname | | Course 1 | C1 | | Course 2 | C2 | | Course 3 | C3 | And the following "course enrolments" exist: | user | course | role | | teacher | C1 | editingteacher | | teacher | C2 | teacher | And the following "activities" exist: | activity | name | intro | course | idnumber | | quiz | Quiz 1 | Quiz 1 for testing the Add menu | C1 | quiz1 | | qbank | Qbank 1 | Question bank 1 for testing the Add menu | C1 | qbank1 | | qbank | Qbank 2 | Question bank 2 for testing the Add menu | C1 | qbank2 | | qbank | Qbank 3 | Question bank 3 for testing the Add menu | C2 | qbank3 | | qbank | Qbank 4 | Question bank 4 for testing the Add menu | C3 | qbank4 | And the following "question categories" exist: | contextlevel | reference | name | | Activity module | qbank1 | Test questions 1 | | Activity module | qbank2 | Test questions 2 | | Activity module | qbank3 | Test questions 3 | | Activity module | qbank4 | Test questions 4 | | Activity module | quiz1 | Test questions 5 | And I am on the "Quiz 1" "mod_quiz > Edit" page logged in as "teacher" Scenario: Switching to another bank shows the expected banks When I open the "last" add to quiz menu And I follow "from question bank" When I click on "Switch bank" "button" Then I should see "Quiz 1" And I should see "Qbank 1" And I should see "Qbank 2" But I should not see "Qbank 3" Scenario: Searching for another shared bank shows the expected bank When I open the "last" add to quiz menu And I follow "from question bank" When I click on "Switch bank" "button" And I open the autocomplete suggestions list Then "Qbank 3" "autocomplete_suggestions" should exist But "Qbank 4" "autocomplete_suggestions" should not exist And I click on "C2 - Qbank 3" item in the autocomplete list And I should see "Current bank: Qbank 3" And I should see "Default for Qbank 3" Scenario: Viewing question banks not in the current course show as recently accessed Given "teacher" has recently viewed the "qbank1" "qbank" question bank And "teacher" has recently viewed the "qbank2" "qbank" question bank And "teacher" has recently viewed the "qbank3" "qbank" question bank And "teacher" has recently viewed the "Quiz 1" "quiz" question bank When I open the "last" add to quiz menu And I follow "from question bank" And I click on "Switch bank" "button" Then I should see "Qbank 3" But I should not see "Qbank 4" PK9B\Z$a?a?2tests/behat/editing_add_from_question_bank.featurenu[@mod @mod_quiz @javascript Feature: Adding questions to a quiz from the question bank In order to re-use questions As a teacher I want to add questions from the question bank Background: Given the following "users" exist: | username | firstname | lastname | email | | teacher1 | Teacher | 1 | teacher1@example.com | And the following "courses" exist: | fullname | shortname | format | | Test courseCours test & < > " ' & | C1 | weeks | And the following "course enrolments" exist: | user | course | role | | teacher1 | C1 | editingteacher | And the following "activities" exist: | activity | name | intro | course | idnumber | | quiz | Quiz 1 | Quiz 1 for testing the Add menu | C1 | quiz1 | | qbank | QbankBanqueq 1 & < > " ' & | Question bank 1 for testing the Add menu | C1 | qbank1 | | qbank | Question Bank A | Question Bank A for testing qbank name | C1 | qbankA | | qbank | Question Bank B | Question Bank B for testing qbank name | C1 | qbankB | And the following "question categories" exist: | contextlevel | reference | name | | Activity module | quiz1 | Test questions | | Activity module | qbank1 | Qbank questions | | Activity module | qbankA | Qbank Questions 1 | | Activity module | qbankB | Qbank Questions 2 | And the following "questions" exist: | questioncategory | qtype | name | user | questiontext | idnumber | | Test questions | essay | question 01 name | admin | Question 01 text | | | Test questions | essay | question 02 name | teacher1 | Question 02 text | qidnum | | Qbank questions | essay | question 03 name | teacher1 | Question 03 text | q3idnum | | Qbank questions | essay | question 04 name | teacher1 | Question 04 text | q4idnum | | Qbank Questions 1 | truefalse | TF1 | admin | Qbank 1 question | | | Qbank Questions 2 | truefalse | TF2 | admin | Qbank 2 question | | Scenario: The questions can be filtered by tag Given I am on the "question 01 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 choose "Edit question" action for "question 02 name" in the question bank And I set the following fields to these values: | Tags | bar | And I press "id_submitbutton" When I am on the "Quiz 1" "mod_quiz > Edit" page And I open the "last" add to quiz menu And I follow "from question bank" And I apply question bank filter "Category" with value "Test questions" Then I should see "foo" in the "question 01 name" "table_row" And I should see "bar" in the "question 02 name" "table_row" And I should see "qidnum" in the "question 02 name" "table_row" When I apply question bank filter "Tag" with value "foo" And I should see "question 01 name" in the "categoryquestions" "table" And I should not see "question 02 name" in the "categoryquestions" "table" Scenario: The questions can be filtered by tag on a shared question bank Given the "multilang" filter is "on" And the "multilang" filter applies to "content and headings" And I am on the "question 03 name" "core_question > edit" page logged in as teacher1 And I set the following fields to these values: | Tags | qbanktag1 | And I press "Save changes" And I am on the "question 04 name" "core_question > edit" page logged in as teacher1 And I set the following fields to these values: | Tags | qbanktag2 | And I press "Save changes" When I am on the "Quiz 1" "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 "Qbank questions" Then I should see "qbanktag1" in the "question 03 name" "table_row" And I should see "qbanktag2" in the "question 04 name" "table_row" And I apply question bank filter "Tag" with value "qbanktag1" And I should see "question 03 name" in the "categoryquestions" "table" And I should not see "question 04 name" in the "categoryquestions" "table" Scenario: The question modal can be paginated Given the following "question categories" exist: | contextlevel | reference | name | | Activity module | quiz1 | My collection | And 45 "questions" exist with the following data: | questioncategory | My collection | | qtype | essay | | name | Feature question [count] | | questiontext | Write about topic [count] | | user | teacher1 | # Sadly, the above step generates questions which sort like FQ1, FQ11, FQ12, ..., FQ19, FQ2, FQ20, ... # so the expected paging behaviour is not immediately intuitive with 20 questions per page. When I am on the "Quiz 1" "mod_quiz > Edit" page logged in as teacher1 And I open the "last" add to quiz menu And I follow "from question bank" And I apply question bank filter "Category" with value "Test questions" And I should see "question 01 name" in the "categoryquestions" "table" And I should see "question 02 name" in the "categoryquestions" "table" And I should not see "Feature question" in the "categoryquestions" "table" And I set the field "Category" to "My collection" And I press "Apply filters" And I wait until the page is ready Then I should not see "question 01 name" in the "categoryquestions" "table" And I should see "Feature question 1" in the "categoryquestions" "table" And I should see "Feature question 27" in the "categoryquestions" "table" And I should not see "Feature question 28" in the "categoryquestions" "table" And I click on "2" "link" in the ".pagination" "css_element" And I wait until the page is ready And I should not see "Feature question 27" in the "categoryquestions" "table" And I should see "Feature question 28" in the "categoryquestions" "table" And I should see "Feature question 45" in the "categoryquestions" "table" And I should not see "Feature question 5" And I click on "3" "link" in the ".pagination" "css_element" And I wait until the page is ready And I should not see "Feature question 45" in the "categoryquestions" "table" And I should see "Feature question 5" And I should see "Feature question 9" Scenario: After closing and reopening the modal, it still works Given the following "question categories" exist: | contextlevel | reference | name | | Activity module | quiz1 | My collection | And the following "question" exists: | questioncategory | My collection | | qtype | essay | | name | Feature question | | questiontext | Write about topic | | user | teacher1 | When I am on the "Quiz 1" "mod_quiz > Edit" page logged in as teacher1 And I open the "last" add to quiz menu And I follow "from question bank" 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 I set the field "Category" to "My collection" And I press "Apply filters" Then I should see "Feature question" Scenario: Questions are added in the right place with multiple sections Given the following "questions" exist: | questioncategory | qtype | name | questiontext | | Test questions | essay | question 03 name | question 03 text | And quiz "Quiz 1" contains the following questions: | question | page | | question 01 name | 1 | | question 02 name | 2 | And quiz "Quiz 1" contains the following sections: | heading | firstslot | shuffle | | Section 1 | 1 | 0 | | Section 2 | 2 | 0 | And I log in as "teacher1" And I am on the "Quiz 1" "mod_quiz > Edit" page When I open the "Page 1" add to quiz menu And I follow "from question bank" And I apply question bank filter "Category" with value "Test questions" And I set the field with xpath "//tr[contains(normalize-space(.), 'question 03 name')]//input[@type='checkbox']" to "1" And I click on "Add selected questions to the quiz" "button" Then I should see "question 03 name" on quiz page "1" And I should see "question 01 name" before "question 03 name" on the edit quiz page Scenario: Add several selected questions from the question bank Given I am on the "Quiz 1" "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 apply question bank filter "Category" with value "Test questions" And I set the field with xpath "//input[@type='checkbox' and @id='qbheadercheckbox']" to "1" And I press "Add selected questions to the quiz" Then I should see "question 01 name" on quiz page "1" And I should see "question 02 name" on quiz page "2" Scenario: Adding a question to quiz from a shared question bank Given the "multilang" filter is "on" And the "multilang" filter applies to "content and headings" And I am on the "Quiz 1" "mod_quiz > Edit" page logged in as "teacher1" When I open the "last" add to quiz menu And I follow "from question bank" Then I should see "Current bank: Quiz 1" And I apply question bank filter "Category" with value "Test questions" And I should see "question 01 name" 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 "Qbank questions" And I should see "question 03 name" But I should not see "question 01 name" And I click on "Select" "checkbox" in the "question 03 name" "table_row" And I click on "Add selected questions to the quiz" "button" And I should see "question 03 name" And "Qbank 1 & < > " "text" should appear after "question 03 name" "text" @javascript Scenario: Validate the sorting while adding questions from question bank Given the following "questions" exist: | questioncategory | qtype | name | questiontext | | Test questions | multichoice | question 03 name | question 03 name text | And I am on the "Quiz 1" "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 apply question bank filter "Category" with value "Test questions" And I click on "Sort by Question ascending" "link" Then "question 01 name" "text" should appear before "question 02 name" "text" And I click on "Sort by Question descending" "link" And "question 03 name" "text" should appear before "question 01 name" "text" And I follow "Sort by Question type ascending" Then "question 01 name" "text" should appear before "question 03 name" "text" And I follow "Sort by Question type descending" Then "question 03 name" "text" should appear before "question 01 name" "text" Scenario: Shuffle option could be set before adding any question to the quiz Given the following "questions" exist: | questioncategory | qtype | name | questiontext | | Test questions | essay | question 03 name | question 03 text | And I log in as "teacher1" And I am on the "Quiz 1" "mod_quiz > Edit" page When I set the field "Shuffle" to "1" And I open the "last" add to quiz menu And I follow "from question bank" And I apply question bank filter "Category" with value "Test questions" Then I should see "question 01 name" Scenario: Question bank names are displayed in quiz questions Given quiz "Quiz 1" contains the following questions: | question | page | | TF1 | 1 | | TF2 | 1 | When I am on the "Quiz 1" "mod_quiz > Edit" page logged in as teacher1 Then I should see "Question Bank A" in the "TF1" "list_item" And I should see "Question Bank B" in the "TF2" "list_item" Scenario: User doesn't see the option to switch to a bank they can't use Given the "multilang" filter is "on" And the "multilang" filter applies to "content and headings" And the following "users" exist: | username | firstname | lastname | email | | teacher2 | Teacher | 2 | teacher2@example.com | And the following "role" exists: | shortname | noquestions | | name | Cannot use questions | | moodle/question:usemine | prohibit | | moodle/question:useall | prohibit | And the following "course enrolments" exist: | user | course | role | | teacher2 | C1 | editingteacher | And I am on the "Quiz 1" "mod_quiz > Edit" page logged in as "teacher2" 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 "Qbank questions" And I click on "Select" "checkbox" in the "question 03 name" "table_row" And I click on "Add selected questions to the quiz" "button" When the following "role assigns" exist: | user | role | contextlevel | reference | | teacher2 | noquestions | Activity module | qbank1 | And I open the "Page 1" add to quiz menu And I follow "from question bank" And I click on "Switch bank" "button" Then "Qbank 1 & < > \" ' &" "link" should not exist in the "Select question bank" "dialogue" @javascript Scenario: Don't show the edit link if the user doesn't have permission Given the following "courses" exist: | fullname | shortname | category | | Course 2 | C2 | 0 | And the following "activities" exist: | activity | name | course | idnumber | | qbank | Question Bank C | C2 | qbankC | And the following "question categories" exist: | contextlevel | reference | name | | Activity module | qbankC | Qbank Questions 3 | And the following "questions" exist: | questioncategory | qtype | name | questiontext | | Qbank Questions 3 | truefalse | Shared question | Answer the question | And quiz "Quiz 1" contains the following questions: | question | page | | TF1 | 1 | | Shared question | 1 | When I am on the "Quiz 1" "mod_quiz > Edit" page logged in as teacher1 Then "TF1" "link" should exist And "Question Bank A" "link" should exist And "Shared question" "link" should not exist And "Question Bank C" "link" should not exist PK9B\+)''&tests/behat/attempt_sequential.featurenu[@mod @mod_quiz Feature: Attempt a quiz in a sequential mode As a student I should not be able to see the previous questions Background: Given the following "users" exist: | username | firstname | lastname | email | | student | Student | One | student@example.com | | teacher | Teacher | One | teacher@example.com | And the following "courses" exist: | fullname | shortname | category | | Course 1 | C1 | 0 | And the following "course enrolments" exist: | user | course | role | | student | C1 | student | | teacher | C1 | teacher | And the following "activities" exist: | activity | name | intro | course | idnumber | preferredbehaviour | navmethod | | quiz | Quiz 1 | Quiz 1 description | C1 | quiz1 | immediatefeedback | sequential | 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 | TF1 | First question | | Test questions | truefalse | TF2 | Second question | | Test questions | truefalse | TF3 | Third question | | Test questions | truefalse | TF4 | Fourth question | And quiz "Quiz 1" contains the following questions: | question | page | requireprevious | | TF1 | 1 | 1 | | TF2 | 2 | 1 | | TF3 | 3 | 1 | | TF4 | 4 | 1 | @javascript Scenario Outline: As a student I should not be able to navigate out of sequence if sequential navigation is on. Given I am on the "Quiz 1" "mod_quiz > View" page logged in as "student" And I press "Attempt quiz" And I should see "First question" When I am on the "Quiz 1 > student > Attempt 1 > " "mod_quiz > Attempt view" page And I should see "" Then I should not see "" Examples: | pagenumber | canseequestion | cannotseequestion | | 1 | First question | Second question | | 2 | Second question | First question | | 4 | First question | Fourth question | @javascript Scenario: As a student I should not be able to navigate out of sequence by opening new windows on the same quiz. Given the following config values are set as admin: | config | value | plugin | | autosaveperiod | 60 | quiz | And I am on the "Quiz 1" "mod_quiz > View" page logged in as "student" And I press "Attempt quiz" And I should see "First question" And I click on "True" "radio" in the "First question" "question" And I click on "Next page" "button" When I am on the "Quiz 1 > student > Attempt 1 > 3" "mod_quiz > Attempt view" page And I click on "True" "radio" in the "Third question" "question" And I should see "Third question" And I click on "Next page" "button" And I am on the "Quiz 1 > student > Attempt 1 > 1" "mod_quiz > Attempt view" page Then I should see "Fourth question" @javascript Scenario: As a student I should not be able to save my data by opening a given page out of sequence. Given the following config values are set as admin: | config | value | plugin | | autosaveperiod | 1 | quiz | When I am on the "Quiz 1" "mod_quiz > View" page logged in as "student" And I press "Attempt quiz" And I am on the "Quiz 1 > student > Attempt 1 > 2" "mod_quiz > Attempt view" page And I should see "Second question" And I click on "True" "radio" in the "Second question" "question" And I wait "2" seconds And I am on the "Quiz 1 > student > Attempt 1 > 1" "mod_quiz > Attempt view" page Then I should see "Second question" @javascript Scenario: As a student I can review question I have finished in any order Given user "student" has attempted "Quiz 1" with responses: | slot | response | | 1 | True | | 2 | False | | 3 | False | | 4 | False | When I am on the "Quiz 1" "mod_quiz > View" page logged in as "student" And I follow "Review" And I am on the "Quiz 1 > student > Attempt 1 > 3" "mod_quiz > Attempt view" page And I should see "Third question" And I am on the "Quiz 1 > student > Attempt 1 > 2" "mod_quiz > Attempt view" page Then I should see "Second question" PK9B\)Aj-j-!tests/behat/attempt_basic.featurenu[@mod @mod_quiz Feature: Attempt a quiz As a student In order to demonstrate what I know I need to be able to attempt quizzes Background: Given the following "users" exist: | username | firstname | lastname | email | | student | Student | One | student@example.com | And the following "courses" exist: | fullname | shortname | category | | Course 1 | C1 | 0 | And the following "course enrolments" exist: | user | course | role | | student | C1 | student | And the following "activities" exist: | activity | name | intro | course | idnumber | grade | navmethod | | quiz | Quiz 1 | Quiz 1 description | C1 | quiz1 | 100 | free | | quiz | Quiz 2 | Quiz 2 description | C1 | quiz2 | 6 | free | | quiz | Quiz 3 | Quiz 3 description | C1 | quiz3 | 100 | free | | quiz | Quiz 4 | Quiz 4 description | C1 | quiz4 | 100 | sequential | | qbank | Qbank 1 | Question bank 1 | C1 | qbank1 | | | 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 | TF1 | First question | | Test questions | truefalse | TF2 | Second question | | Test questions | truefalse | TF3 | Third question | | Test questions | truefalse | TF4 | Fourth question | | Test questions | truefalse | TF5 | Fifth question | | Test questions | truefalse | TF6 | Sixth question | And quiz "Quiz 1" contains the following questions: | question | page | maxmark | | TF1 | 1 | | | TF2 | 1 | 3.0 | And quiz "Quiz 2" contains the following questions: | question | page | | TF1 | 1 | | TF2 | 1 | | TF3 | 2 | | TF4 | 3 | | TF5 | 4 | | TF6 | 4 | And quiz "Quiz 2" contains the following sections: | heading | firstslot | shuffle | | Section 1 | 1 | 0 | | Section 2 | 3 | 0 | | | 4 | 1 | | Section 3 | 5 | 1 | And quiz "Quiz 3" contains the following questions: | question | page | | TF1 | 1 | | TF2 | 2 | And quiz "Quiz 4" contains the following questions: | question | page | | TF1 | 1 | | TF2 | 2 | @javascript Scenario: Attempt a quiz with a single unnamed section, review and re-attempt Given user "student" has attempted "Quiz 1" with responses: | slot | response | | 1 | True | | 2 | False | When I am on the "Quiz 1" "mod_quiz > View" page logged in as "student" And I follow "Review" And I should see "Status" Then I should see "Started" And I should see "Completed" And I should see "Duration" And I should see "Marks" And I should see "Grade" And I should see "25.00 out of 100.00" And I follow "Finish review" And I should see "Highest grade: 25.00 / 100.00" And I press "Re-attempt quiz" @javascript Scenario: Attempt a quiz with multiple sections Given I am on the "Quiz 2" "mod_quiz > View" page logged in as "student" When I press "Attempt quiz" Then I should see "Section 1" in the "Quiz navigation" "block" And I should see question "1" in section "Section 1" in the quiz navigation And I should see question "2" in section "Section 1" in the quiz navigation And I should see question "3" in section "Section 2" in the quiz navigation And I should see question "4" in section "Untitled section" in the quiz navigation And I should see question "5" in section "Section 3" in the quiz navigation And I should see question "6" in section "Section 3" in the quiz navigation And I click on "True" "radio" in the "First question" "question" And I follow "Finish attempt ..." And I should see question "1" in section "Section 1" in the quiz navigation And I should see question "2" in section "Section 1" in the quiz navigation And I should see question "3" in section "Section 2" in the quiz navigation And I should see question "4" in section "Untitled section" in the quiz navigation And I should see question "5" in section "Section 3" in the quiz navigation And I should see question "6" in section "Section 3" in the quiz navigation And I should see "Section 1" in the "quizsummaryofattempt" "table" And I should see "Section 2" in the "quizsummaryofattempt" "table" And I should see "Untitled section" in the "quizsummaryofattempt" "table" And I should see "Section 3" in the "quizsummaryofattempt" "table" And I press "Submit all and finish" And I click on "Submit all and finish" "button" in the "Submit all your answers and finish?" "dialogue" And I should see "1.00 out of 6.00 (16.67%)" in the "Grade" "table_row" And I should see question "1" in section "Section 1" in the quiz navigation And I should see question "2" in section "Section 1" in the quiz navigation And I should see question "3" in section "Section 2" in the quiz navigation And I should see question "4" in section "Untitled section" in the quiz navigation And I should see question "5" in section "Section 3" in the quiz navigation And I should see question "6" in section "Section 3" in the quiz navigation And I follow "Show one page at a time" And I should see "First question" And I should not see "Third question" And I should see "Next page" And I follow "Show all questions on one page" And I should see "Fourth question" And I should see "Sixth question" And I should not see "Next page" @javascript Scenario: Next and previous navigation Given I am on the "Quiz 3" "mod_quiz > View" page logged in as "student" When I press "Attempt quiz" Then I should see "First question" And I should not see "Second question" And I press "Next page" And I should see "Second question" And I should not see "First question" And I click on "Finish attempt ..." "button" in the "region-main" "region" And I should see "Summary of attempt" And I press "Return to attempt" And I should see "Second question" And I should not see "First question" And I press "Previous page" And I should see "First question" And I should not see "Second question" And I follow "Finish attempt ..." And I press "Submit all and finish" And I should see "Once you submit your answers, you won’t be able to change them." in the "Submit all your answers and finish?" "dialogue" And I should see "Questions without a response: 2" in the "Submit all your answers and finish?" "dialogue" And I click on "Submit all and finish" "button" in the "Submit all your answers and finish?" "dialogue" And I should see "0.00 out of 100.00" in the "Grade" "table_row" And I should see "First question" And I should see "Second question" And I follow "Show one page at a time" And I should see "0.00 out of 100.00" in the "Grade" "table_row" And I should see "First question" And I should not see "Second question" And I follow "Next page" And "Grade" "table_row" should not exist And I should see "Second question" And I should not see "First question" And I follow "Previous page" And I should see "0.00 out of 100.00" in the "Grade" "table_row" And I should see "First question" And I should not see "Second question" @javascript Scenario: Next and previous with sequential navigation method Given I am on the "Quiz 4" "mod_quiz > View" page logged in as "student" When I press "Attempt quiz" Then I should see "First question" And I should not see "Second question" And I press "Next page" And I should see "Second question" And I should not see "First question" And "Previous page" "button" should not exist And I click on "Finish attempt ..." "button" in the "region-main" "region" And I should see "Summary of attempt" And I press "Submit all and finish" And I should see "Once you submit your answers, you won’t be able to change them." in the "Submit all your answers and finish?" "dialogue" And I should not see "Questions without a response: 2" in the "Submit all your answers and finish?" "dialogue" And I click on "Submit" "button" in the "Submit all your answers and finish?" "dialogue" And I should see "First question" And I should see "Second question" And I follow "Show one page at a time" And I should see "First question" And I should not see "Second question" And I follow "Next page" And I should see "Second question" And I should not see "First question" And I follow "Previous page" And I should see "First question" And I should not see "Second question" @javascript Scenario: Take a quiz with number of attempts set Given the following "activities" exist: | activity | name | course | grade | navmethod | attempts | | quiz | Quiz 5 | C1 | 100 | free | 2 | And the following "questions" exist: | questioncategory | qtype | name | questiontext | | Test questions | truefalse | TF7 | First question | And quiz "Quiz 5" contains the following questions: | question | page | | TF7 | 1 | And user "student" has attempted "Quiz 5" with responses: | slot | response | | 1 | True | When I am on the "Quiz 5" "mod_quiz > View" page logged in as "student" Then I should see "Attempts allowed: 2" And I should not see "No more attempts are allowed" And I press "Re-attempt quiz" And I should see "First question" And I click on "Finish attempt ..." "button" in the "region-main" "region" And I press "Submit all and finish" And I should see "Once you submit your answers, you won’t be able to change them." in the "Submit all your answers and finish?" "dialogue" And I click on "Submit all and finish" "button" in the "Submit all your answers and finish?" "dialogue" And I follow "Finish review" And I should not see "Re-attempt quiz" And I should see "No more attempts are allowed" @javascript Scenario: Student still sees the same version after the question is edited. Given I am on the "Quiz 1" "mod_quiz > View" page logged in as "student" And I press "Attempt quiz" And I should see "First question" And I click on "False" "radio" in the "First question" "question" And I press "Finish attempt ..." And I log out And I am on the "Quiz 1" "mod_quiz > View" page logged in as "admin" And I press "Preview quiz" And I click on "Edit question" "link" in the "First question" "question" And I set the field "Question text" to "First question version 2" And I press "id_submitbutton" And I should see "v2 (latest)" in the "First question" "question" And I log out When I am on the "Quiz 1" "mod_quiz > View" page logged in as "student" And I press "Continue your attempt" Then I should see "First question" And I should not see "First question version 2" PK9B\^::&tests/behat/editing_repaginate.featurenu[@mod @mod_quiz Feature: Edit quiz page - pagination In order to build a quiz laid out in pages the way I want As a teacher I need to be able to add and remove pages, and repaginate. Background: Given the following "users" exist: | username | firstname | lastname | email | | teacher1 | T1 | Teacher1 | teacher1@example.com | | student1 | S1 | Student1 | student1@example.com | And the following "courses" exist: | fullname | shortname | category | | Course 1 | C1 | 0 | And the following "course enrolments" exist: | user | course | role | | teacher1 | C1 | editingteacher | | student1 | C1 | student | And the following "activities" exist: | activity | name | course | idnumber | questionsperpage | navmethod | | quiz | Quiz 1 | C1 | quiz1 | 0 | sequential | And the following "questions" exist: | questioncategory | qtype | name | questiontext | | Default for Quiz 1 | truefalse | 1 | Question1 text | | Default for Quiz 1 | truefalse | 2 | Question2 text | | Default for Quiz 1 | truefalse | 3 | Question3 text | | Default for Quiz 1 | truefalse | 4 | Question4 text | | Default for Quiz 1 | truefalse | 5 | Question5 text | | Default for Quiz 1 | truefalse | 6 | Question6 text | | Default for Quiz 1 | truefalse | 7 | Question7 text | | Default for Quiz 1 | truefalse | 8 | Question8 text | | Default for Quiz 1 | truefalse | 9 | Question9 text | | Default for Quiz 1 | truefalse | 10 | Question10 text | And quiz "Quiz 1" contains the following questions: | question | page | | 1 | 1 | | 2 | 1 | | 3 | 1 | | 4 | 1 | | 5 | 1 | | 6 | 1 | | 7 | 1 | | 8 | 1 | | 9 | 1 | And I am on the "Quiz 1" "mod_quiz > Edit" page logged in as teacher1 @javascript Scenario: Repaginate questions using "add page break" or "Remove page break" icons # Start repaginating. When I click on the "Add" page break icon after question "Question1 text" # Confirm that only Question 1 is on quiz page 1. Then I should see "Question1 text" on quiz page "1" # Confirm that Question 2 ~ Question 9 are on quiz page 2. And I should see "Question2 text" on quiz page "2" And I should see "Question3 text" on quiz page "2" And I should see "Question4 text" on quiz page "2" And I should see "Question5 text" on quiz page "2" And I should see "Question6 text" on quiz page "2" And I should see "Question7 text" on quiz page "2" And I should see "Question8 text" on quiz page "2" And I should see "Question9 text" on quiz page "2" When I click on the "Remove" page break icon after question "Question1 text" # Confirm that all questions are on quiz page 1. Then I should see "Question1 text" on quiz page "1" And I should see "Question2 text" on quiz page "1" And I should see "Question3 text" on quiz page "1" And I should see "Question4 text" on quiz page "1" And I should see "Question5 text" on quiz page "1" And I should see "Question6 text" on quiz page "1" And I should see "Question7 text" on quiz page "1" And I should see "Question8 text" on quiz page "1" And I should see "Question9 text" on quiz page "1" # Confirm that there is no second page. And I should not see "Page 2" in the "region-main" "region" # Add a question. And I open the "last" add to quiz menu And I choose "from question bank" in the open action menu When I click on "Add to quiz" "link" in the "Question10 text" "table_row" # Confirm that all questions are on quiz page 1. Then I should see "Question1 text" on quiz page "1" And I should see "Question2 text" on quiz page "1" And I should see "Question3 text" on quiz page "1" And I should see "Question4 text" on quiz page "1" And I should see "Question5 text" on quiz page "1" And I should see "Question6 text" on quiz page "1" And I should see "Question7 text" on quiz page "1" And I should see "Question8 text" on quiz page "1" And I should see "Question9 text" on quiz page "1" And I should see "Question10 text" on quiz page "1" # Confirm that there is no second page. And I should not see "Page 2" in the "region-main" "region" When I click on the "Add" page break icon after question "Question2 text" # Confirm that Question 1 and Question 2 are on quiz page 1. Then I should see "Question1 text" on quiz page "1" And I should see "Question2 text" on quiz page "1" # Confirm that Question 3 ~ Question 10 are on quiz page 2. And I should see "Question3 text" on quiz page "2" And I should see "Question4 text" on quiz page "2" And I should see "Question5 text" on quiz page "2" And I should see "Question6 text" on quiz page "2" And I should see "Question7 text" on quiz page "2" And I should see "Question8 text" on quiz page "2" And I should see "Question9 text" on quiz page "2" And I should see "Question10 text" on quiz page "2" # Confirm that there is no third page. And I should not see "Page 3" in the "region-main" "region" When I click on the "Add" page break icon after question "Question1 text" # Confirm that only Question 1 is on quiz page 1. Then I should see "Question1 text" on quiz page "1" # Confirm that only Question 2 is on quiz page 2. And I should see "Question2 text" on quiz page "2" # Confirm that Question 3 ~ Question 10 are on quiz page 3. And I should see "Question3 text" on quiz page "3" And I should see "Question4 text" on quiz page "3" And I should see "Question5 text" on quiz page "3" And I should see "Question6 text" on quiz page "3" And I should see "Question7 text" on quiz page "3" And I should see "Question8 text" on quiz page "3" And I should see "Question9 text" on quiz page "3" And I should see "Question10 text" on quiz page "3" When I click on the "Remove" page break icon after question "Question2 text" # Confirm that only Question 1 is on quiz page 1. Then I should see "Question1 text" on quiz page "1" # Confirm that Question 2 ~ Question 10 are on quiz page 2. And I should see "Question2 text" on quiz page "2" And I should see "Question3 text" on quiz page "2" And I should see "Question4 text" on quiz page "2" And I should see "Question5 text" on quiz page "2" And I should see "Question6 text" on quiz page "2" And I should see "Question7 text" on quiz page "2" And I should see "Question8 text" on quiz page "2" And I should see "Question9 text" on quiz page "2" And I should see "Question10 text" on quiz page "2" # Confirm that there is no third page. And I should not see "Page 3" in the "region-main" "region" When I click on the "Remove" page break icon after question "Question1 text" # Confirm that all questions are on quiz page 1. Then I should see "Question1 text" on quiz page "1" And I should see "Question2 text" on quiz page "1" And I should see "Question3 text" on quiz page "1" And I should see "Question4 text" on quiz page "1" And I should see "Question5 text" on quiz page "1" And I should see "Question6 text" on quiz page "1" And I should see "Question7 text" on quiz page "1" And I should see "Question8 text" on quiz page "1" And I should see "Question9 text" on quiz page "1" And I should see "Question10 text" on quiz page "1" # Confirm that there is no second page. And I should not see "Page 2" in the "region-main" "region" # Repaginate to 1 question per page. When I press "Repaginate" And I set the field "menuquestionsperpage" to "1" And I click on "Go" "button" in the "Repaginate" "dialogue" # Confirm there is only 1 question per page - maximum of 10 pages. Then I should see "Question1 text" on quiz page "1" And I should see "Question2 text" on quiz page "2" And I should see "Question3 text" on quiz page "3" And I should see "Question4 text" on quiz page "4" And I should see "Question5 text" on quiz page "5" And I should see "Question6 text" on quiz page "6" And I should see "Question7 text" on quiz page "7" And I should see "Question8 text" on quiz page "8" And I should see "Question9 text" on quiz page "9" And I should see "Question10 text" on quiz page "10" # Confirm that there is no eleventh page. And I should not see "Page 11" in the "region-main" "region" # Repaginate to 2 questions per page. When I press "Repaginate" And I set the field "menuquestionsperpage" to "2" And I click on "Go" "button" in the "Repaginate" "dialogue" # Confirm there are only 2 questions per page - maximum of 5 pages. Then I should see "Question1 text" on quiz page "1" And I should see "Question2 text" on quiz page "1" And I should see "Question3 text" on quiz page "2" And I should see "Question4 text" on quiz page "2" And I should see "Question5 text" on quiz page "3" And I should see "Question6 text" on quiz page "3" And I should see "Question7 text" on quiz page "4" And I should see "Question8 text" on quiz page "4" And I should see "Question9 text" on quiz page "5" And I should see "Question10 text" on quiz page "5" # Confirm that there is no sixth page. And I should not see "Page 6" in the "region-main" "region" When I click on the "Add" page break icon after question "Question9 text" # Confirm there are only 2 questions per page until Question 8. Then I should see "Question1 text" on quiz page "1" And I should see "Question2 text" on quiz page "1" And I should see "Question3 text" on quiz page "2" And I should see "Question4 text" on quiz page "2" And I should see "Question5 text" on quiz page "3" And I should see "Question6 text" on quiz page "3" And I should see "Question7 text" on quiz page "4" And I should see "Question8 text" on quiz page "4" # Confirm only Question 9 is on quiz page 5. And I should see "Question9 text" on quiz page "5" # Confirm Question 10 is now on quiz page 6. And I should see "Question10 text" on quiz page "6" # Repaginate with unlimited questions per page (All questions on Page 1). When I press "Repaginate" And I set the field "menuquestionsperpage" to "Unlimited" And I click on "Go" "button" in the "Repaginate" "dialogue" # Confirm all questions are on page 1. Then I should see "Question1 text" on quiz page "1" And I should see "Question2 text" on quiz page "1" And I should see "Question3 text" on quiz page "1" And I should see "Question4 text" on quiz page "1" And I should see "Question5 text" on quiz page "1" And I should see "Question6 text" on quiz page "1" And I should see "Question7 text" on quiz page "1" And I should see "Question8 text" on quiz page "1" And I should see "Question9 text" on quiz page "1" And I should see "Question10 text" on quiz page "1" # Confirm that there is no second page. And I should not see "Page 2" in the "region-main" "region" @javascript Scenario: Teacher can repaginate questions using the move icon # Add page breaks. Given I click on the "Add" page break icon after question "Question3 text" And I click on the "Add" page break icon after question "Question7 text" # Move Question 6 after Question 3 using the move icon. When I move "Question6 text" to "After Question 3" in the quiz by clicking the move icon # Confirm that Question 6 moved to page 1 after Question 3. Then I should see "Question1 text" on quiz page "1" And I should see "Question2 text" on quiz page "1" And I should see "Question3 text" on quiz page "1" And I should see "Question6 text" on quiz page "1" # Confirm that Question 4, 5 and 7 remain on page 2. And I should see "Question4 text" on quiz page "2" And I should see "Question5 text" on quiz page "2" And I should see "Question7 text" on quiz page "2" # Confirm that Question 8 and 9 remain on page 3. And I should see "Question8 text" on quiz page "3" And I should see "Question9 text" on quiz page "3" # Move Question 7 and 1 after Question 9 using the move icon. When I move "Question7 text" to "After Question 9" in the quiz by clicking the move icon And I move "Question1 text" to "After Question 9" in the quiz by clicking the move icon # Confirm that Question 2, 3 and 6 remain on page 1. Then I should see "Question2 text" on quiz page "1" And I should see "Question3 text" on quiz page "1" And I should see "Question6 text" on quiz page "1" # Confirm that Question 4 and 5 remain on page 2. And I should see "Question4 text" on quiz page "2" And I should see "Question5 text" on quiz page "2" # Confirm that Question 8 and 9 remain on page 3. And I should see "Question8 text" on quiz page "3" And I should see "Question9 text" on quiz page "3" # Confirm that Question 7 and 1 moved to page 3 after Question 9. And I should see "Question7 text" on quiz page "3" And I should see "Question1 text" on quiz page "3" @javascript Scenario: Quiz questions are displayed on specified page when previewed and attempted # Add page breaks. Given I click on the "Add" page break icon after question "Question3 text" And I click on the "Add" page break icon after question "Question7 text" # Move Question 1 after Question 9 . And I move "Question1 text" to "After Question 9" in the quiz by clicking the move icon And I am on the "Quiz 1" "quiz activity" page When I press "Preview quiz" # Confirm the questions displayed per page when quiz is previewed. Then I should see "Question2 text" And I should see "Question3 text" And I press "Next page" And I should see "Question4 text" And I should see "Question5 text" And I should see "Question6 text" And I should see "Question7 text" And I press "Next page" And I should see "Question8 text" And I should see "Question9 text" And I should see "Question1 text" When I am on the "Quiz 1" "quiz activity" page logged in as student1 # Confirm the questions displayed per page when quiz is attempted. And I press "Attempt quiz" Then I should see "Question2 text" And I should see "Question3 text" And I press "Next page" And I should see "Question4 text" And I should see "Question5 text" And I should see "Question6 text" And I should see "Question7 text" And I press "Next page" And I should see "Question8 text" And I should see "Question9 text" And I should see "Question1 text" PK9B\4 tests/behat/quiz_reset.featurenu[@mod @mod_quiz Feature: Quiz reset In order to reuse past quizzes As a teacher I need to remove all previous data. Background: Given the following "users" exist: | username | firstname | lastname | email | | teacher1 | Terry1 | Teacher1 | teacher1@example.com | | student1 | Sam1 | Student1 | student1@example.com | And the following "courses" exist: | fullname | shortname | category | | Course 1 | C1 | 0 | And the following "course enrolments" exist: | user | course | role | | teacher1 | C1 | editingteacher | | student1 | C1 | student | And the following "groups" exist: | name | course | idnumber | | Group 1 | C1 | G1 | | Group 2 | C1 | G2 | And the following "activities" exist: | activity | name | intro | course | idnumber | | quiz | Test quiz name | Test quiz description | 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 | TF1 | First question | And quiz "Test quiz name" contains the following questions: | question | page | | TF1 | 1 | And user "student1" has attempted "Test quiz name" with responses: | slot | response | | 1 | True | Scenario: Use course reset to clear all attempt data When I log in as "teacher1" And I am on the "Course 1" "reset" page And I set the following fields to these values: | All quiz attempts | 1 | And I press "Reset course" And I press "Continue" And I am on the "Test quiz name" "mod_quiz > Grades report" page Then I should see "Attempts: 0" Scenario: Use course reset to remove user overrides. Given the following "mod_quiz > user overrides" exist: | quiz | user | attempts | | Test quiz name | student1 | 2 | When I log in as "teacher1" And I am on the "Course 1" "reset" page And I set the field "All user overrides" to "1" And I press "Reset course" And I press "Continue" And I am on the "Test quiz name" "mod_quiz > User overrides" page Then I should not see "Sam1 Student1" Scenario: Use course reset to remove group overrides. Given the following "mod_quiz > group overrides" exist: | quiz | group | attempts | | Test quiz name | G1 | 2 | When I log in as "teacher1" And I am on the "Course 1" "reset" page And I set the following fields to these values: | All group overrides | 1 | And I press "Reset course" And I press "Continue" And I am on the "Test quiz name" "mod_quiz > Group overrides" page Then I should not see "Group 1" PK9B\ /tests/behat/quiz_single_activity_course.featurenu[@mod @mod_quiz @format @format_singleactivity Feature: Teacher can build quiz in a single activity format course In order to build a quiz in a single activity course format As a teacher I should be able to change course format Background: Given the following "users" exist: | username | firstname | lastname | email | | student | Student | 1 | student1@example.com | And the following "courses" exist: | fullname | shortname | format | activitytype | | Course 1 | C1 | singleactivity | quiz | And the following "course enrolments" exist: | user | course | role | | student | C1 | student | # Create multiple question banks to have questions from different question banks. And the following "activities" exist: | activity | course | name | idnumber | | quiz | C1 | Quiz 1 | q1 | | qbank | C1 | Question bank 1 | qbank1 | | qbank | C1 | Question bank 2 | qbank2 | And the following "question categories" exist: | contextlevel | reference | name | | Activity module | q1 | Test questions | | Activity module | qbank1 | Qbank questions 1 | | Activity module | qbank2 | Qbank questions 2 | And the following "questions" exist: | questioncategory | qtype | template | name | | Test questions | multichoice | one_of_four | MCQ1 | | Test questions | multichoice | one_of_four | MCQ2 | | Qbank questions 1 | truefalse | | TFQ1 | | Qbank questions 2 | truefalse | | TFQ2 | | Test questions | random | | Random (Test questions) | # Add questions from different question categories and question banks to the quiz. # Add at least 1 random question. And quiz "Quiz 1" contains the following questions: | question | page | | MCQ1 | 1 | | TFQ1 | 1 | | TFQ2 | 2 | | Random (Test questions) | 3 | @javascript Scenario: Student can preview and answer quiz in a single activity format course Given I am on the "Quiz 1" "quiz activity" page logged in as student # By attempting the quiz, it can be confirmed that the quiz questions have been added successfully. When I press "Attempt quiz" And I click on "One" "qtype_multichoice > Answer" And I set the following fields to these values: | False | 1 | And I press "Next page" And I set the following fields to these values: | True | 1 | And I press "Next page" And I click on "Three" "qtype_multichoice > Answer" And I press "Finish attempt ..." # Confirm that answers are successfully saved. And I should see "Answer saved" And I press "Submit all and finish" And I click on "Submit all and finish" "button" in the "Submit all your answers and finish?" "dialogue" # Confirm that quiz is finished successfully. Then I should see "Finished" in the "Status" "table_row" PK9B\++tests/behat/editing_add.featurenu[@mod @mod_quiz Feature: Edit quiz page - adding things In order to build the quiz I want my students to attempt As a teacher I need to be able to add questions to the quiz. 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 | intro | course | idnumber | | quiz | Quiz 1 | Quiz 1 for testing the Add menu | C1 | quiz1 | And the following "question categories" exist: | contextlevel | reference | name | | Activity module | quiz1 | Test questions | And I am on the "Quiz 1" "mod_quiz > Edit" page logged in as "teacher1" @javascript Scenario: Add some new question to the quiz using '+ a new question' options of the 'Add' menu. When I open the "last" add to quiz menu And I follow "a new question" 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" And I should see "Essay 01 new" on quiz page "1" And I open the "Page 1" add to quiz menu And I follow "a new question" 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 02 new" And I set the field "Question text" to "Please write 200 words about Essay 02" And I press "id_submitbutton" And I should see "Essay 01 new" on quiz page "1" And I should see "Essay 02 new" on quiz page "1" And I open the "Page 1" add to quiz menu And I follow "a new question" 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 03 new" And I set the field "Question text" to "Please write 300 words about Essay 03" And I press "id_submitbutton" And I should see "Essay 01 new" on quiz page "1" And I should see "Essay 02 new" on quiz page "1" And I should see "Essay 03 new" on quiz page "1" And I open the "Page 1" add to quiz menu And I follow "a new question" 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 04 new" And I set the field "Question text" to "Please write 300 words about Essay 04" And I press "id_submitbutton" And I should see "Essay 01 new" on quiz page "1" And I should see "Essay 02 new" on quiz page "1" And I should see "Essay 03 new" on quiz page "1" And I should see "Essay 04 new" on quiz page "1" # Repaginate as two questions per page. And I should not see "Page 2" When I press "Repaginate" Then I should see "Repaginate with" And I set the field "menuquestionsperpage" to "2" And I click on "Go" "button" in the "Repaginate" "dialogue" And I should see "Essay 01 new" on quiz page "1" And I should see "Essay 02 new" on quiz page "1" And I should see "Essay 03 new" on quiz page "2" And I should see "Essay 04 new" on quiz page "2" # Add a question to page 2. When I open the "Page 2" add to quiz menu And I choose "a new question" in the open action menu 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 for page 2" And I set the field "Question text" to "Please write 200 words about Essay for page 2" And I press "id_submitbutton" And I should see "Essay 01 new" on quiz page "1" And I should see "Essay 02 new" on quiz page "1" And I should see "Essay 03 new" on quiz page "2" And I should see "Essay 04 new" on quiz page "2" And I should see "Essay for page 2" on quiz page "2" @javascript Scenario: Add questions from question bank to the quiz. In order to be able to add questions from question bank to the quiz, first we create some new questions in various categories and add them to the question bank. # Create a couple of sub categories. Given the following "question categories" exist: | contextlevel | reference | questioncategory | name | | Activity module | quiz1 | Test questions | Subcat 1 | | Activity module | quiz1 | Test questions | Subcat 2 | When I am on the "Quiz 1" "mod_quiz > question categories" page And I select "Questions" from the "Question bank tertiary navigation" singleselect And I should see "Question bank" # Create the Essay 01 question. When 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" Then I should see "Adding an Essay question" And I set the field "Question name" to "Essay 01" And I set the field "Question text" to "Please write 100 words about Essay 01" And I press "id_submitbutton" Then I should see "Question bank" And I should see "Essay 01" # Create the Essay 02 question. When 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" Then I should see "Adding an Essay question" And I set the field "Question name" to "Essay 02" And I set the field "Question text" to "Please write 200 words about Essay 02" And I press "id_submitbutton" Then I should see "Question bank" And I should see "Essay 02" # Create the Essay 03 question. And I wait until the page is ready When 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" Then I should see "Adding an Essay question" And I set the field "Question name" to "Essay 03" And I set the field "Question text" to "Please write 300 words about Essay 03" And I press "id_submitbutton" Then I should see "Question bank" And I should see "Essay 03" # Create the TF 01 question. When 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" Then I should see "Adding a True/False question" And I set the field "Question name" to "TF 01" And I set the field "Question text" to "The correct answer is true" And I set the field "Correct answer" to "True" And I press "id_submitbutton" Then I should see "Question bank" And I should see "TF 01" # Create the TF 02 question. When 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" Then I should see "Adding a True/False question" And I set the field "Question name" to "TF 02" And I set the field "Question text" to "The correct answer is false" And I set the field "Correct answer" to "False" And I press "id_submitbutton" Then I should see "Question bank" And I should see "TF 02" # Add questions from question bank using the Add menu. # Add Essay 03 from question bank. And I am on the "Quiz 1" "mod_quiz > Edit" page And I open the "last" add to quiz menu And I follow "from question bank" Then the "Add selected questions to the quiz" "button" should be disabled And I click on "Select" "checkbox" in the "Essay 03" "table_row" Then the "Add selected questions to the quiz" "button" should be enabled And I click on "Add to quiz" "link" in the "Essay 03" "table_row" And I should see "Essay 03" on quiz page "1" # Add Essay 01 from question bank. And I open the "Page 1" add to quiz menu And I follow "from question bank" And I click on "Add to quiz" "link" in the "Essay 01" "table_row" And I should see "Essay 03" on quiz page "1" And I should see "Essay 01" on quiz page "1" # Add Esay 02 from question bank. And I open the "Page 1" add to quiz menu And I follow "from question bank" And I click on "Add to quiz" "link" in the "Essay 02" "table_row" And I should see "Essay 03" on quiz page "1" And I should see "Essay 01" on quiz page "1" And I should see "Essay 02" on quiz page "1" # Add a random question. And I open the "Page 1" add to quiz menu And I follow "a random question" And I press "Add random question" And I should see "Essay 03" on quiz page "1" And I should see "Essay 01" on quiz page "1" And I should see "Essay 02" on quiz page "1" And I should see "Random" on quiz page "1" # Repaginate as one question per page. And I should not see "Page 2" When I press "Repaginate" Then I should see "Repaginate with" And I set the field "menuquestionsperpage" to "1" When I click on "Go" "button" in the "Repaginate" "dialogue" And I should see "Essay 03" on quiz page "1" And I should see "Essay 01" on quiz page "2" And I should see "Essay 02" on quiz page "3" And I should see "Random" on quiz page "4" # Add a random question to page 4. And I open the "Page 4" add to quiz menu And I choose "a new question" in the open action menu 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 for page 4" And I set the field "Question text" to "Please write 200 words about Essay for page 4" And I press "id_submitbutton" And I should see "Essay 03" on quiz page "1" And I should see "Essay 01" on quiz page "2" And I should see "Essay 02" on quiz page "3" And I should see "Random" on quiz page "4" And I should see "Essay for page 4" on quiz page "4" @accessibility @javascript Scenario: Check the accessibility of the quiz questions page Given the following "questions" exist: | questioncategory | qtype | name | questiontext | | Test questions | truefalse | First question | Answer the first question | | Test questions | truefalse | Other question | Answer the first question | And quiz "Quiz 1" contains the following questions: | question | page | | First question | 1 | When I reload the page Then I should see "First question" And the page should meet accessibility standards PK9B\p1tests/behat/quiz_no_calendar_capabilities.featurenu[@mod @mod_quiz Feature: Quiz with no calendar capabilites In order to allow work effectively As a teacher I need to be able to create quiz even when I cannot edit calendar events 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 "activity" exists: | activity | quiz | | course | C1 | | idnumber | 00001 | | name | Test quiz name | | intro | Test quiz description | | section | 1 | And I log in as "admin" And I am on "Course 1" course homepage And I follow "Test quiz name" And I navigate to "Settings" in current page administration And I set the following fields to these values: | id_timeopen_enabled | 1 | | id_timeopen_day | 1 | | id_timeopen_month | 1 | | id_timeopen_year | 2017 | | id_timeclose_enabled | 1 | | id_timeclose_day | 1 | | id_timeclose_month | 2 | | id_timeclose_year | 2017 | And I press "Save and return to course" And I am on the "Course 1" "permissions" page And I override the system permissions of "Teacher" role with: | capability | permission | | moodle/calendar:manageentries | Prohibit | And I log out Scenario: Editing a quiz When I am on the "Test quiz name" "mod_quiz > View" page logged in as "teacher1" And I navigate to "Settings" in current page administration And I set the following fields to these values: | id_timeopen_year | 2018 | | id_timeclose_year | 2018 | And I press "Save and return to course" Then I should see "Test quiz name" PK9B\m MM'tests/behat/editing_edit_random.featurenu[@mod @mod_quiz @javascript Feature: Editing random questions already in a quiz based on category and tags In order to have better assessment As a teacher I want to be able to update how questions are randomly picked from the question bank Background: Given the following "users" exist: | username | firstname | lastname | email | | teacher1 | Teacher | 1 | t1@example.com | And the following "courses" exist: | fullname | shortname | | Course 1 | C1 | And the following "course enrolments" exist: | user | course | role | | teacher1 | C1 | editingteacher | And the following "activities" exist: | activity | name | intro | course | idnumber | | quiz | Quiz 1 | Quiz 1 for testing the Add random question form | C1 | quiz1 | | qbank | Qbank 1 | Qbank 1 for testing the Edit random question form | C1 | qbank1 | And the following "question categories" exist: | contextlevel | reference | name | | Activity module | quiz1 | Questions Category 1| | Activity module | quiz1 | Questions Category 2| | Activity module | qbank1 | Questions Category 3| And the following "questions" exist: | questioncategory | qtype | name | user | questiontext | | Questions Category 1 | essay | question 1 name | admin | Question 1 text | | Questions Category 1 | essay | question 2 name | teacher1 | Question 2 text | | Questions Category 3 | essay | question 3 name | teacher1 | Question 3 text | And the following "core_question > Tags" exist: | question | tag | | question 1 name | easy | | question 1 name | essay | | question 2 name | hard | | question 2 name | essay | | question 3 name | essay | Scenario: Editing tags on one slot does not delete the rest Given I am on the "Quiz 1" "mod_quiz > Edit" page logged in as "teacher1" And I open the "last" add to quiz menu And I follow "a random question" And I apply question bank filter "Category" with value "Questions Category 1" # To actually reproduce MDL-68733 it would be better to set tags easy,essay here, and then below just delete one tag. # However, the state of Behat for autocomplete fields does not let us actually do that. And I apply question bank filter "Tag" with value "easy" And I press "Add random question" And I open the "Page 1" add to quiz menu And I follow "a random question" And I apply question bank filter "Category" with value "Questions Category 1" And I apply question bank filter "Tag" with value "hard" And I press "Add random question" And I follow "Add page break" When I click on "Configure question" "link" in the "Random (Questions Category 1) based on filter condition with tags: easy" "list_item" And I apply question bank filter "Tag" with value "essay" And I press "Update filter conditions" Then I should see "Random (Questions Category 1) based on filter condition with tags: essay" on quiz page "1" And I should see "Random (Questions Category 1) based on filter condition with tags: hard" on quiz page "2" And I click on "Configure question" "link" in the "Random (Questions Category 1) based on filter condition with tags: hard" "list_item" And "hard" "autocomplete_selection" should be visible Scenario: Switch banks when editing a random question Given I am on the "Quiz 1" "mod_quiz > Edit" page logged in as "teacher1" And I open the "last" add to quiz menu And I follow "a random question" And I apply question bank filter "Category" with value "Questions Category 1" And I apply question bank filter "Tag" with value "essay" And I press "Add random question" When I click on "Configure question" "link" in the "Random (Questions Category 1) based on filter condition with tags: essay" "list_item" And I press "Switch bank" And I click on "Qbank 1" "link" in the "Select question bank" "dialogue" And the field "filter-value-qtagids" matches value "essay" And I apply question bank filter "Category" with value "Questions Category 3 (1)" And I should see "question 3 name" And I press "Update filter conditions" Then I should see "Random (Questions Category 3) based on filter condition with tags: essay" on quiz page "1" Scenario: "Go back" from bank switcher keeps existing filter values. Given I am on the "Quiz 1" "mod_quiz > Edit" page logged in as "teacher1" And I open the "last" add to quiz menu And I follow "a random question" And I apply question bank filter "Category" with value "Questions Category 1" And I apply question bank filter "Tag" with value "essay" And I press "Add random question" When I click on "Configure question" "link" in the "Random (Questions Category 1) based on filter condition with tags: essay" "list_item" And the field "filter-value-category" matches value "   Questions Category 1 (2)" And the field "filter-value-qtagids" matches value "essay" And I press "Switch bank" And I press "Go back" Then the field "filter-value-category" matches value "   Questions Category 1 (2)" And the field "filter-value-qtagids" matches value "essay" PK9B\ f+tests/behat/attempt_multiple_grades.featurenu[@mod @mod_quiz Feature: Attempt a quiz with multiple grades As a student In order to demonstrate multiple skills at once I need to be able to attempt quizzes with multiple grades setup Background: Given the following "users" exist: | username | | student | And the following "courses" exist: | fullname | shortname | | Course 1 | C1 | And the following "course enrolments" exist: | user | course | role | | student | C1 | student | 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 | @javascript Scenario: Navigation to, and display of, grading setup Given the following "questions" exist: | questioncategory | qtype | name | questiontext | | Test questions | truefalse | Reading | Can you read this? | | Test questions | truefalse | Listening | Can you hear this? | And the following "mod_quiz > grade items" exist: | quiz | name | | Quiz 1 | Grade for reading | | Quiz 1 | Grade for listening | | Quiz 1 | Unused grade item | And quiz "Quiz 1" contains the following questions: | question | page | grade item | | Reading | 1 | Grade for reading | | Listening | 1 | Grade for listening | When I am on the "Quiz 1" "quiz activity" page logged in as "student" And I click on "Attempt quiz" "button" And I set the field "True" in the "Can you read this?" "question" to "1" And I set the field "False" in the "Can you hear this?" "question" to "1" And I press "Finish attempt ..." And I press "Submit all and finish" And I click on "Submit all and finish" "button" in the "Submit all your answers and finish?" "dialogue" Then I should see "1.00 out of 1.00 (100%)" in the "Grade for reading" "table_row" And I should see "0.00 out of 1.00 (0%)" in the "Grade for listening" "table_row" And I should not see "Unused grade item" And I should see "1.00/2.00" in the "Marks" "table_row" # Funny order because 'Grade' also appears in other rows. And I should see "Grade" in the "50.00 out of 100.00" "table_row" And I follow "Finish review" And I should not see "Unused grade item" And I should see "1.00/2.00" in the "Marks" "table_row" And I should see "Grade" in the "50.00 out of 100.00" "table_row" @javascript Scenario: The grade item should not show a percentage in sectional scoring if the question's max mark is 100 Given the following "questions" exist: | questioncategory | qtype | name | questiontext | | Test questions | truefalse | Reading | Can you read this? | And the following "mod_quiz > grade items" exist: | quiz | name | | Quiz 1 | Grade for reading | And quiz "Quiz 1" contains the following questions: | question | page | grade item | | Reading | 1 | Grade for reading | When I am on the "Quiz 1" "mod_quiz > Edit" page logged in as "admin" And I set the max mark for question "Reading" to "100.0" And I am on the "Quiz 1" "quiz activity" page logged in as "student" And I click on "Attempt quiz" "button" And I set the field "True" in the "Can you read this?" "question" to "1" And I press "Finish attempt ..." And I press "Submit all and finish" And I click on "Submit all and finish" "button" in the "Submit all your answers and finish?" "dialogue" Then I should see "100.00 out of 100.00" in the "Grade for reading" "table_row" PK9B\@#xx)tests/behat/editing_move_by_click.featurenu[@mod @mod_quiz Feature: Edit quiz page - drag-and-drop In order to change the layout of a quiz I built As a teacher I need to be able to drag and drop questions to reorder them. 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 | 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 | | Test questions | truefalse | Question A | This is question 01 | | Test questions | truefalse | Question B | This is question 02 | | Test questions | truefalse | Question C | This is question 03 | And quiz "Quiz 1" contains the following questions: | question | page | | Question A | 1 | | Question B | 1 | | Question C | 2 | And I log in as "teacher1" And I am on the "Quiz 1" "mod_quiz > Edit" page @javascript Scenario: Re-order questions by clicking on the move icon. Then I should see "Question A" on quiz page "1" And I should see "Question B" on quiz page "1" And I should see "Question C" on quiz page "2" When I move "Question A" to "After Question 2" in the quiz by clicking the move icon Then I should see "Question B" on quiz page "1" And I should see "Question A" on quiz page "1" And I should see "Question B" before "Question A" on the edit quiz page And I should see "Question C" on quiz page "2" When I move "Question A" to "After Page 2" in the quiz by clicking the move icon Then I should see "Question B" on quiz page "1" And I should see "Question A" on quiz page "2" And I should see "Question C" on quiz page "2" And I should see "Question A" before "Question C" on the edit quiz page When I move "Question B" to "After Question 2" in the quiz by clicking the move icon Then I should see "Question A" on quiz page "1" And I should see "Question B" on quiz page "1" And I should see "Question C" on quiz page "1" And I should see "Question A" before "Question B" on the edit quiz page And I should see "Question B" before "Question C" on the edit quiz page When I move "Question B" to "After Page 1" in the quiz by clicking the move icon Then I should see "Question B" on quiz page "1" And I should see "Question A" on quiz page "1" And I should see "Question C" on quiz page "1" And I should see "Question B" before "Question A" on the edit quiz page And I should see "Question A" before "Question C" on the edit quiz page When I click on the "Add" page break icon after question "Question A" When I open the "Page 2" add to quiz menu And I choose "a new question" in the open action menu And I set the field "item_qtype_description" to "1" And I press "submitbutton" Then I should see "Adding a description" And I set the following fields to these values: | Question name | Question D | | Question text | Useful info | And I press "id_submitbutton" Then I should see "Question B" on quiz page "1" And I should see "Question A" on quiz page "1" And I should see "Question C" on quiz page "2" And I should see "Question D" on quiz page "2" And I should see "Question B" before "Question A" on the edit quiz page And I should see "Question C" before "Question D" on the edit quiz page And "Question B" should have number "1" on the edit quiz page And "Question A" should have number "2" on the edit quiz page And "Question C" should have number "3" on the edit quiz page And "Question D" should have number "i" on the edit quiz page When I move "Question D" to "After Question 2" in the quiz by clicking the move icon Then I should see "Question B" on quiz page "1" And I should see "Question D" on quiz page "1" And I should see "Question A" on quiz page "1" And I should see "Question C" on quiz page "2" And I should see "Question B" before "Question A" on the edit quiz page And I should see "Question A" before "Question D" on the edit quiz page And "Question B" should have number "1" on the edit quiz page And "Question D" should have number "i" on the edit quiz page And "Question A" should have number "2" on the edit quiz page And "Question C" should have number "3" on the edit quiz page PK9B\VB B "tests/behat/reattempt_quiz.featurenu[@mod @mod_quiz Feature: Several attempts in a quiz As a student In order to demonstrate what I know I need to be able to attempt quizzes and sometimes take multiple attempts Background: Given the following "users" exist: | username | firstname | lastname | email | | student1 | Student | One | student1@example.com | | student2 | Student | One | student2@example.com | | teacher | Teacher | One | teacher@example.com | And the following "courses" exist: | fullname | shortname | category | | Course 1 | C1 | 0 | And the following "course enrolments" exist: | user | course | role | | student1 | C1 | student | | student2 | C1 | student | | teacher | C1 | teacher | And the following "activities" exist: | activity | name | intro | course | idnumber | preferredbehaviour | navmethod | | quiz | Quiz 1 | Quiz 1 description | C1 | quiz1 | immediatefeedback | free | 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 | TF1 | First question | | Test questions | truefalse | TF2 | Second question | And quiz "Quiz 1" contains the following questions: | question | page | requireprevious | | TF1 | 1 | 1 | | TF2 | 2 | 1 | # Add some attempts And user "student1" has attempted "Quiz 1" with responses: | slot | response | | 1 | True | | 2 | False | And user "student2" has attempted "Quiz 1" with responses: | slot | response | | 1 | True | | 2 | True | # Add a second attempt by student1 And user "student1" has attempted "Quiz 1" with responses: | slot | response | | 1 | False | | 2 | False | @javascript Scenario: The redo question buttons are visible after 2 attempts are preset for student1. Given I am on the "Quiz 1" "mod_quiz > View" page logged in as "student1" Then "Re-attempt quiz" "button" should exist And I should see "Finished" in the "Attempt 1" "list_item" And I should see "1.00/2.00" in the "Attempt 1" "list_item" And I should see "Finished" in the "Attempt 2" "list_item" And I should see "0.00/2.00" in the "Attempt 2" "list_item" PK9B\8 _ _ tests/behat/add_quiz.featurenu[@mod @mod_quiz Feature: Add a quiz In order to evaluate students As a teacher I need to create a quiz Background: Given the following "users" exist: | username | firstname | lastname | email | | teacher1 | Terry1 | Teacher1 | teacher1@example.com | | student1 | Sam1 | Student1 | student1@example.com | And the following "courses" exist: | fullname | shortname | category | | Course 1 | C1 | 0 | And the following "course enrolments" exist: | user | course | role | | teacher1 | C1 | editingteacher | | student1 | C1 | student | And the following "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 | And I log out And I am on the "Test quiz name" "quiz activity" page logged in as student1 And I press "Attempt quiz" Then I should see "Question 1" And I should see "Answer the first question" And I set the field "True" to "1" And I press "Finish attempt ..." And I should see "Answer saved" And I press "Submit all and finish" @javascript @skip_chrome_zerosize Scenario: Add and configure small quiz and perform an attempt as a student with Javascript enabled Then I click on "Submit all and finish" "button" in the "Submit all your answers and finish?" "dialogue" And I should see "So you think it is true" And I should see "Thank you, this is the general feedback" And I should see "The correct answer is 'False'." And I follow "Finish review" And I should see "Highest grade: 0.00 / 10.00." Scenario: Add and configure small quiz and perform an attempt as a student with Javascript disabled Then I should see "So you think it is true" And I should see "Thank you, this is the general feedback" And I should see "The correct answer is 'False'." And I follow "Finish review" And I should see "Highest grade: 0.00 / 10.00." PK9B\}}tests/behat/behat_mod_quiz.phpnu[. /** * Steps definitions related to mod_quiz. * * @package mod_quiz * @category test * @copyright 2014 Marina Glancy * @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__ . '/../../../../question/tests/behat/behat_question_base.php'); use Behat\Gherkin\Node\TableNode; use Behat\Mink\Exception\DriverException; use Behat\Mink\Exception\ExpectationException; use mod_quiz\quiz_attempt; use mod_quiz\quiz_settings; /** * Steps definitions related to mod_quiz. * * @copyright 2014 Marina Glancy * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class behat_mod_quiz 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 quiz 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 | * | View | Quiz name | The quiz info page (view.php) | * | Edit | Quiz name | The edit quiz page (edit.php) | * | Group overrides | Quiz name | The manage group overrides page | * | User overrides | Quiz name | The manage user overrides page | * | Grades report | Quiz name | The overview report for a quiz | * | Responses report | Quiz name | The responses report for a quiz | * | Manual grading report | Quiz name | The manual grading report for a quiz | * | Statistics report | Quiz name | The statistics report for a quiz | * | Attempt review | Quiz name > username > [Attempt] attempt no | Review page for a given attempt (review.php) | * | Question bank | Quiz name | The question bank page for a quiz | * * @param string $type identifies which type of page this is, e.g. 'Attempt review'. * @param string $identifier identifies the particular page, e.g. 'Test quiz > student > Attempt 1'. * @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 { global $DB; switch (strtolower($type)) { case 'view': return new moodle_url('/mod/quiz/view.php', ['id' => $this->get_cm_by_quiz_name($identifier)->id]); case 'edit': return new moodle_url('/mod/quiz/edit.php', ['cmid' => $this->get_cm_by_quiz_name($identifier)->id]); case 'multiple grades setup': return new moodle_url('/mod/quiz/editgrading.php', ['cmid' => $this->get_cm_by_quiz_name($identifier)->id]); case 'group overrides': return new moodle_url('/mod/quiz/overrides.php', ['cmid' => $this->get_cm_by_quiz_name($identifier)->id, 'mode' => 'group']); case 'user overrides': return new moodle_url('/mod/quiz/overrides.php', ['cmid' => $this->get_cm_by_quiz_name($identifier)->id, 'mode' => 'user']); case 'grades report': return new moodle_url('/mod/quiz/report.php', ['id' => $this->get_cm_by_quiz_name($identifier)->id, 'mode' => 'overview']); case 'responses report': return new moodle_url('/mod/quiz/report.php', ['id' => $this->get_cm_by_quiz_name($identifier)->id, 'mode' => 'responses']); case 'statistics report': return new moodle_url('/mod/quiz/report.php', ['id' => $this->get_cm_by_quiz_name($identifier)->id, 'mode' => 'statistics']); case 'manual grading report': return new moodle_url('/mod/quiz/report.php', ['id' => $this->get_cm_by_quiz_name($identifier)->id, 'mode' => 'grading']); case 'attempt view': list($quizname, $username, $attemptno, $pageno) = explode(' > ', $identifier); $pageno = intval($pageno); $pageno = $pageno > 0 ? $pageno - 1 : 0; $attemptno = (int) trim(str_replace ('Attempt', '', $attemptno)); $quiz = $this->get_quiz_by_name($quizname); $quizcm = get_coursemodule_from_instance('quiz', $quiz->id, $quiz->course); $user = $DB->get_record('user', ['username' => $username], '*', MUST_EXIST); $attempt = $DB->get_record('quiz_attempts', ['quiz' => $quiz->id, 'userid' => $user->id, 'attempt' => $attemptno], '*', MUST_EXIST); return new moodle_url('/mod/quiz/attempt.php', [ 'attempt' => $attempt->id, 'cmid' => $quizcm->id, 'page' => $pageno ]); case 'attempt review': if (substr_count($identifier, ' > ') !== 2) { throw new coding_exception('For "attempt review", name must be ' . '"{Quiz name} > {username} > Attempt {attemptnumber}", ' . 'for example "Quiz 1 > student > Attempt 1".'); } list($quizname, $username, $attemptno) = explode(' > ', $identifier); $attemptno = (int) trim(str_replace ('Attempt', '', $attemptno)); $quiz = $this->get_quiz_by_name($quizname); $user = $DB->get_record('user', ['username' => $username], '*', MUST_EXIST); $attempt = $DB->get_record('quiz_attempts', ['quiz' => $quiz->id, 'userid' => $user->id, 'attempt' => $attemptno], '*', MUST_EXIST); return new moodle_url('/mod/quiz/review.php', ['attempt' => $attempt->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_quiz_name($identifier)->id, ]); case 'question categories': return new moodle_url('/question/bank/managecategories/category.php', [ 'cmid' => $this->get_cm_by_quiz_name($identifier)->id, ]); default: throw new Exception('Unrecognised quiz page type "' . $type . '."'); } } /** * Get a quiz by name. * * @param string $name quiz name. * @return stdClass the corresponding DB row. */ protected function get_quiz_by_name(string $name): stdClass { global $DB; return $DB->get_record('quiz', ['name' => $name], '*', MUST_EXIST); } /** * Get a quiz cmid from the quiz name. * * @param string $name quiz name. * @return stdClass cm from get_coursemodule_from_instance. */ protected function get_cm_by_quiz_name(string $name): stdClass { $quiz = $this->get_quiz_by_name($name); return get_coursemodule_from_instance('quiz', $quiz->id, $quiz->course); } /** * Put the specified questions on the specified pages of a given quiz. * * The first row should be column names: * | question | page | maxmark | requireprevious | * The first two of those are required. The others are optional. * * question needs to uniquely match a question name. * page is a page number. Must start at 1, and on each following * row should be the same as the previous, or one more. * maxmark What the question is marked out of. Defaults to question.defaultmark. * requireprevious The question can only be attempted after the previous one was completed. * * Then there should be a number of rows of data, one for each question you want to add. * * For backwards-compatibility reasons, specifying the column names is optional * (but strongly encouraged). If not specified, the columns are asseumed to be * | question | page | maxmark |. * * @param string $quizname the name of the quiz to add questions to. * @param TableNode $data information about the questions to add. * * @Given /^quiz "([^"]*)" contains the following questions:$/ */ public function quiz_contains_the_following_questions($quizname, TableNode $data) { global $DB; $quiz = $this->get_quiz_by_name($quizname); // Deal with backwards-compatibility, optional first row. $firstrow = $data->getRow(0); if (!in_array('question', $firstrow) && !in_array('page', $firstrow)) { if (count($firstrow) == 2) { $headings = ['question', 'page']; } else if (count($firstrow) == 3) { $headings = ['question', 'page', 'maxmark']; } else { throw new ExpectationException('When adding questions to a quiz, you should give 2 or three 3 things: ' . ' the question name, the page number, and optionally the maximum mark. ' . count($firstrow) . ' values passed.', $this->getSession()); } $rows = $data->getRows(); array_unshift($rows, $headings); $data = new TableNode($rows); } // Add the questions. $lastpage = 0; foreach ($data->getHash() as $questiondata) { if (!array_key_exists('question', $questiondata)) { throw new ExpectationException('When adding questions to a quiz, ' . 'the question name column is required.', $this->getSession()); } if (!array_key_exists('page', $questiondata)) { throw new ExpectationException('When adding questions to a quiz, ' . 'the page number column is required.', $this->getSession()); } // Question id, category and type. $sql = 'SELECT q.id AS id, qbe.questioncategoryid AS category, q.qtype AS qtype 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' => $questiondata['question']], MUST_EXIST); // Page number. $page = clean_param($questiondata['page'], PARAM_INT); if ($page <= 0 || (string) $page !== $questiondata['page']) { throw new ExpectationException('The page number for question "' . $questiondata['question'] . '" must be a positive integer.', $this->getSession()); } if ($page < $lastpage || $page > $lastpage + 1) { throw new ExpectationException('When adding questions to a quiz, ' . 'the page number for each question must either be the same, ' . 'or one more, then the page number for the previous question.', $this->getSession()); } $lastpage = $page; // Max mark. if (!array_key_exists('maxmark', $questiondata) || $questiondata['maxmark'] === '') { $maxmark = null; } else { $maxmark = clean_param($questiondata['maxmark'], PARAM_LOCALISEDFLOAT); if (!is_numeric($maxmark) || $maxmark < 0) { throw new ExpectationException('The max mark for question "' . $questiondata['question'] . '" must be a positive number.', $this->getSession()); } } if ($question->qtype == 'random') { if (!array_key_exists('includingsubcategories', $questiondata) || $questiondata['includingsubcategories'] === '') { $includingsubcategories = false; } else { $includingsubcategories = clean_param($questiondata['includingsubcategories'], PARAM_BOOL); } $filter = [ 'category' => [ 'jointype' => \qbank_managecategories\category_condition::JOINTYPE_DEFAULT, 'values' => [$question->category], 'filteroptions' => ['includesubcategories' => $includingsubcategories], ], ]; $filtercondition['filter'] = $filter; $settings = quiz_settings::create($quiz->id); $structure = \mod_quiz\structure::create_for_quiz($settings); $structure->add_random_questions($page, 1, $filtercondition); } else { // Add the question. quiz_add_quiz_question($question->id, $quiz, $page, $maxmark); } // Look for additional properties we might want to set on the new slot. $extraslotproperties = []; // Display number (allowing editable customised question number). if (array_key_exists('displaynumber', $questiondata)) { if (!is_number($questiondata['displaynumber']) && !is_string($questiondata['displaynumber'])) { throw new ExpectationException('Displayed question number for "' . $questiondata['question'] . '" should either be \'i\', automatically numbered (eg. 1, 2, 3), or customised (eg. A.1, A.2, 1.1, 1.2)', $this->getSession()); } $extraslotproperties['displaynumber'] = $questiondata['displaynumber']; } // Require previous. if (array_key_exists('requireprevious', $questiondata)) { if ($questiondata['requireprevious'] === '1') { $extraslotproperties['requireprevious'] = 1; } else if ($questiondata['requireprevious'] !== '' && $questiondata['requireprevious'] !== '0') { throw new ExpectationException('Require previous for question "' . $questiondata['question'] . '" should be 0, 1 or blank.', $this->getSession()); } } // Grade item. if (array_key_exists('grade item', $questiondata) && trim($questiondata['grade item']) !== '') { $extraslotproperties['quizgradeitemid'] = $DB->get_field('quiz_grade_items', 'id', ['quizid' => $quiz->id, 'name' => $questiondata['grade item']], MUST_EXIST); } // If there were any extra properties, save them. if ($extraslotproperties) { // We assume that the slot was just created for this row of data is the highest numbered one. $extraslotproperties['id'] = $DB->get_field('quiz_slots', 'MAX(id)', ['quizid' => $quiz->id]); $DB->update_record('quiz_slots', $extraslotproperties); } } $quizobj = quiz_settings::create($quiz->id); $quizobj->get_grade_calculator()->recompute_quiz_sumgrades(); } /** * Put the specified section headings to start at specified pages of a given quiz. * * The first row should be column names: * | heading | firstslot | shufflequestions | * * heading is the section heading text * firstslot is the slot number where the section starts * shuffle whether this section is shuffled (0 or 1) * * Then there should be a number of rows of data, one for each section you want to add. * * @param string $quizname the name of the quiz to add sections to. * @param TableNode $data information about the sections to add. * * @Given /^quiz "([^"]*)" contains the following sections:$/ */ public function quiz_contains_the_following_sections($quizname, TableNode $data) { global $DB; $quiz = $DB->get_record('quiz', ['name' => $quizname], '*', MUST_EXIST); // Add the sections. $previousfirstslot = 0; foreach ($data->getHash() as $rownumber => $sectiondata) { if (!array_key_exists('heading', $sectiondata)) { throw new ExpectationException('When adding sections to a quiz, ' . 'the heading name column is required.', $this->getSession()); } if (!array_key_exists('firstslot', $sectiondata)) { throw new ExpectationException('When adding sections to a quiz, ' . 'the firstslot name column is required.', $this->getSession()); } if (!array_key_exists('shuffle', $sectiondata)) { throw new ExpectationException('When adding sections to a quiz, ' . 'the shuffle name column is required.', $this->getSession()); } if ($rownumber == 0) { $section = $DB->get_record('quiz_sections', ['quizid' => $quiz->id], '*', MUST_EXIST); } else { $section = new stdClass(); $section->quizid = $quiz->id; } // Heading. $section->heading = $sectiondata['heading']; // First slot. $section->firstslot = clean_param($sectiondata['firstslot'], PARAM_INT); if ($section->firstslot <= $previousfirstslot || (string) $section->firstslot !== $sectiondata['firstslot']) { throw new ExpectationException('The firstslot number for section "' . $sectiondata['heading'] . '" must an integer greater than the previous section firstslot.', $this->getSession()); } if ($rownumber == 0 && $section->firstslot != 1) { throw new ExpectationException('The first section must have firstslot set to 1.', $this->getSession()); } // Shuffle. $section->shufflequestions = clean_param($sectiondata['shuffle'], PARAM_INT); if ((string) $section->shufflequestions !== $sectiondata['shuffle']) { throw new ExpectationException('The shuffle value for section "' . $sectiondata['heading'] . '" must be 0 or 1.', $this->getSession()); } if ($rownumber == 0) { $DB->update_record('quiz_sections', $section); } else { $DB->insert_record('quiz_sections', $section); } } if ($section->firstslot > $DB->count_records('quiz_slots', ['quizid' => $quiz->id])) { throw new ExpectationException('The section firstslot must be less than the total number of slots in the quiz.', $this->getSession()); } } /** * Adds a question to the existing quiz with filling the form. * * The form for creating a question should be on one page. * * @When /^I add a "(?P(?:[^"]|\\")*)" question to the "(?P(?:[^"]|\\")*)" quiz with:$/ * @param string $questiontype * @param string $quizname * @param TableNode $questiondata with data for filling the add question form */ public function i_add_question_to_the_quiz_with($questiontype, $quizname, TableNode $questiondata) { $quizname = $this->escape($quizname); $addaquestion = $this->escape(get_string('addaquestion', 'quiz')); $this->execute('behat_navigation::i_am_on_page_instance', [ $quizname, 'mod_quiz > Edit', ]); if ($this->running_javascript()) { $this->execute("behat_action_menu::i_open_the_action_menu_in", ['.slots', "css_element"]); $this->execute("behat_action_menu::i_choose_in_the_open_action_menu", [$addaquestion]); } else { $this->execute('behat_general::click_link', $addaquestion); } $this->finish_adding_question($questiontype, $questiondata); } /** * Set the max mark for a question on the Edit quiz page. * * @When /^I set the max mark for question "(?P(?:[^"]|\\")*)" to "(?P(?:[^"]|\\")*)"$/ * @param string $questionname the name of the question to set the max mark for. * @param string $newmark the mark to set */ public function i_set_the_max_mark_for_quiz_question($questionname, $newmark) { $this->execute('behat_general::click_link', $this->escape(get_string('editmaxmark', 'quiz'))); $this->execute('behat_general::wait_until_exists', ["li input[name=maxmark]", "css_element"]); $this->execute('behat_general::assert_page_contains_text', $this->escape(get_string('edittitleinstructions'))); $this->execute('behat_general::i_type', [$newmark]); $this->execute('behat_general::i_press_named_key', ['', 'enter']); } /** * Open the add menu on a given page, or at the end of the Edit quiz page. * @Given /^I open the "(?P(?:[^"]|\\")*)" add to quiz menu$/ * @param string $pageorlast either "Page n" or "last". */ public function i_open_the_add_to_quiz_menu_for($pageorlast) { if (!$this->running_javascript()) { throw new DriverException('Activities actions menu not available when Javascript is disabled'); } if ($pageorlast == 'last') { $xpath = "//div[@class = 'last-add-menu']//a[contains(@data-bs-toggle, 'dropdown') and contains(., 'Add')]"; } else if (preg_match('~Page (\d+)~', $pageorlast, $matches)) { $xpath = "//li[@id = 'page-{$matches[1]}']//a[contains(@data-bs-toggle, 'dropdown') and contains(., 'Add')]"; } else { throw new ExpectationException("The I open the add to quiz menu step must specify either 'Page N' or 'last'.", $this->getSession()); } $this->find('xpath', $xpath)->click(); } /** * Check whether a particular question is on a particular page of the quiz on the Edit quiz page. * @Given /^I should see "(?P(?:[^"]|\\")*)" on quiz page "(?P\d+)"$/ * @param string $questionname the name of the question we are looking for. * @param number $pagenumber the page it should be found on. */ public function i_should_see_on_quiz_page($questionname, $pagenumber) { $xpath = "//li[contains(., '" . $this->escape($questionname) . "')][./preceding-sibling::li[contains(@class, 'pagenumber')][1][contains(., 'Page " . $pagenumber . "')]]"; $this->execute('behat_general::should_exist', [$xpath, 'xpath_element']); } /** * Check whether a particular question is not on a particular page of the quiz on the Edit quiz page. * @Given /^I should not see "(?P(?:[^"]|\\")*)" on quiz page "(?P\d+)"$/ * @param string $questionname the name of the question we are looking for. * @param number $pagenumber the page it should be found on. */ public function i_should_not_see_on_quiz_page($questionname, $pagenumber) { $xpath = "//li[contains(., '" . $this->escape($questionname) . "')][./preceding-sibling::li[contains(@class, 'pagenumber')][1][contains(., 'Page " . $pagenumber . "')]]"; $this->execute('behat_general::should_not_exist', [$xpath, 'xpath_element']); } /** * Check whether one question comes before another on the Edit quiz page. * The two questions must be on the same page. * @Given /^I should see "(?P(?:[^"]|\\")*)" before "(?P(?:[^"]|\\")*)" on the edit quiz page$/ * @param string $firstquestionname the name of the question that should come first in order. * @param string $secondquestionname the name of the question that should come immediately after it in order. */ public function i_should_see_before_on_the_edit_quiz_page($firstquestionname, $secondquestionname) { $xpath = "//li[contains(., '" . $this->escape($firstquestionname) . "')]/following-sibling::li" . "[contains(., '" . $this->escape($secondquestionname) . "')]"; $this->execute('behat_general::should_exist', [$xpath, 'xpath_element']); } /** * Check the number displayed alongside a question on the Edit quiz page. * @Given /^"(?P(?:[^"]|\\")*)" should have number "(?P(?:[^"]|\\")*)" on the edit quiz page$/ * @param string $questionname the name of the question we are looking for. * @param number $number the number (or 'i') that should be displayed beside that question. */ public function should_have_number_on_the_edit_quiz_page($questionname, $number) { if ($number !== get_string('infoshort', 'quiz')) { // Logic here copied from edit_renderer, which is not ideal, but necessary. $number = get_string('question') . ' ' . $number; } $xpath = "//li[contains(@class, 'slot') and contains(., '" . $this->escape($questionname) . "')]//span[contains(@class, 'slotnumber') and normalize-space(.) = '" . $this->escape($number) . "']"; $this->execute('behat_general::should_exist', [$xpath, 'xpath_element']); } /** * Get the xpath for a partcular add/remove page-break icon. * @param string $addorremoves 'Add' or 'Remove'. * @param string $questionname the name of the question before the icon. * @return string the requried xpath. */ protected function get_xpath_page_break_icon_after_question($addorremoves, $questionname) { return "//li[contains(@class, 'slot') and contains(., '" . $this->escape($questionname) . "')]//a[contains(@class, 'page_split_join') and @title = '" . $addorremoves . " page break']"; } /** * Click the add or remove page-break icon after a particular question. * @When /^I click on the "(Add|Remove)" page break icon after question "(?P(?:[^"]|\\")*)"$/ * @param string $addorremoves 'Add' or 'Remove'. * @param string $questionname the name of the question before the icon to click. */ public function i_click_on_the_page_break_icon_after_question($addorremoves, $questionname) { $xpath = $this->get_xpath_page_break_icon_after_question($addorremoves, $questionname); $this->execute("behat_general::i_click_on", [$xpath, "xpath_element"]); } /** * Assert the add or remove page-break icon after a particular question exists. * @When /^the "(Add|Remove)" page break icon after question "(?P(?:[^"]|\\")*)" should exist$/ * @param string $addorremoves 'Add' or 'Remove'. * @param string $questionname the name of the question before the icon to click. * @return array of steps. */ public function the_page_break_icon_after_question_should_exist($addorremoves, $questionname) { $xpath = $this->get_xpath_page_break_icon_after_question($addorremoves, $questionname); $this->execute('behat_general::should_exist', [$xpath, 'xpath_element']); } /** * Assert the add or remove page-break icon after a particular question does not exist. * @When /^the "(Add|Remove)" page break icon after question "(?P(?:[^"]|\\")*)" should not exist$/ * @param string $addorremoves 'Add' or 'Remove'. * @param string $questionname the name of the question before the icon to click. * @return array of steps. */ public function the_page_break_icon_after_question_should_not_exist($addorremoves, $questionname) { $xpath = $this->get_xpath_page_break_icon_after_question($addorremoves, $questionname); $this->execute('behat_general::should_not_exist', [$xpath, 'xpath_element']); } /** * Check the add or remove page-break link after a particular question contains the given parameters in its url. * * @When /^the "(Add|Remove)" page break link after question "(?P(?:[^"]|\\")*) should contain:$/ * @When /^the "(Add|Remove)" page break link after question "(?P(?:[^"]|\\")*) should contain:"$/ * @param string $addorremoves 'Add' or 'Remove'. * @param string $questionname the name of the question before the icon to click. * @param TableNode $paramdata with data for checking the page break url * @return array of steps. */ public function the_page_break_link_after_question_should_contain($addorremoves, $questionname, $paramdata) { $xpath = $this->get_xpath_page_break_icon_after_question($addorremoves, $questionname); $this->execute("behat_general::i_click_on", [$xpath, "xpath_element"]); } /** * Set Shuffle for shuffling questions within sections * * @param string $heading the heading of the section to change shuffle for. * * @Given /^I click on shuffle for section "([^"]*)" on the quiz edit page$/ */ public function i_click_on_shuffle_for_section($heading) { $xpath = $this->get_xpath_for_shuffle_checkbox($heading); $checkbox = $this->find('xpath', $xpath); $checkbox->click(); } /** * Check the shuffle checkbox for a particular section. * * @param string $heading the heading of the section to check shuffle for * @param int $value whether the shuffle checkbox should be on or off. * * @Given /^shuffle for section "([^"]*)" should be "(On|Off)" on the quiz edit page$/ */ public function shuffle_for_section_should_be($heading, $value) { $xpath = $this->get_xpath_for_shuffle_checkbox($heading); $checkbox = $this->find('xpath', $xpath); $this->ensure_node_is_visible($checkbox); if ($value == 'On' && !$checkbox->isChecked()) { $msg = "Shuffle for section '$heading' is not checked, but you are expecting it to be checked ($value). " . "Check the line with: \nshuffle for section \"$heading\" should be \"$value\" on the quiz edit page" . "\nin your behat script"; throw new ExpectationException($msg, $this->getSession()); } else if ($value == 'Off' && $checkbox->isChecked()) { $msg = "Shuffle for section '$heading' is checked, but you are expecting it not to be ($value). " . "Check the line with: \nshuffle for section \"$heading\" should be \"$value\" on the quiz edit page" . "\nin your behat script"; throw new ExpectationException($msg, $this->getSession()); } } /** * Return the xpath for shuffle checkbox in section heading * @param string $heading * @return string */ protected function get_xpath_for_shuffle_checkbox($heading) { return "//div[contains(@class, 'section-heading') and contains(., '" . $this->escape($heading) . "')]//input[@type = 'checkbox']"; } /** * Move a question on the Edit quiz page by first clicking on the Move icon, * then clicking one of the "After ..." links. * @When /^I move "(?P(?:[^"]|\\")*)" to "(?P(?:[^"]|\\")*)" in the quiz by clicking the move icon$/ * @param string $questionname the name of the question we are looking for. * @param string $target the target place to move to. One of the links in the pop-up like * "After Page 1" or "After Question N". */ public function i_move_question_after_item_by_clicking_the_move_icon($questionname, $target) { $iconxpath = "//li[contains(@class, ' slot ') and contains(., '" . $this->escape($questionname) . "')]//span[contains(@class, 'editing_move')]"; $this->execute("behat_general::i_click_on", [$iconxpath, "xpath_element"]); $this->execute("behat_general::i_click_on", [$this->escape($target), "button"]); } /** * Move a question on the Edit quiz page by dragging a given question on top of another item. * @When /^I move "(?P(?:[^"]|\\")*)" to "(?P(?:[^"]|\\")*)" in the quiz by dragging$/ * @param string $questionname the name of the question we are looking for. * @param string $target the target place to move to. Ether a question name, or "Page N" */ public function i_move_question_after_item_by_dragging($questionname, $target) { $iconxpath = "//li[contains(@class, ' slot ') and contains(., '" . $this->escape($questionname) . "')]//span[contains(@class, 'editing_move')]//img"; $destinationxpath = "//li[contains(@class, ' slot ') or contains(@class, 'pagenumber ')]" . "[contains(., '" . $this->escape($target) . "')]"; $this->execute('behat_general::i_drag_and_i_drop_it_in', [$iconxpath, 'xpath_element', $destinationxpath, 'xpath_element'] ); } /** * Delete a question on the Edit quiz page by first clicking on the Delete icon, * then clicking one of the "After ..." links. * @When /^I delete "(?P(?:[^"]|\\")*)" in the quiz by clicking the delete icon$/ * @param string $questionname the name of the question we are looking for. * @return array of steps. */ public function i_delete_question_by_clicking_the_delete_icon($questionname) { $slotxpath = "//li[contains(@class, ' slot ') and contains(., '" . $this->escape($questionname) . "')]"; $deletexpath = "//a[contains(@class, 'editing_delete')]"; $this->execute("behat_general::i_click_on", [$slotxpath . $deletexpath, "xpath_element"]); $this->execute('behat_general::i_click_on_in_the', ['Yes', "button", "Confirm", "dialogue"] ); } /** * Set the section heading for a given section on the Edit quiz page * * @When /^I change quiz section heading "(?P(?:[^"]|\\")*)" to "(?P(?:[^"]|\\")*)"$/ * @param string $sectionname the heading to change. * @param string $sectionheading the new heading to set. */ public function i_set_the_section_heading_for($sectionname, $sectionheading) { // Empty section headings will have a default names of "Untitled heading". if (empty($sectionname)) { $sectionname = get_string('sectionnoname', 'quiz'); } $this->execute('behat_general::click_link', $this->escape("Edit heading '{$sectionname}'")); $this->execute('behat_general::assert_page_contains_text', $this->escape(get_string('edittitleinstructions'))); $this->execute('behat_general::i_press_named_key', ['', 'backspace']); $this->execute('behat_general::i_type', [$sectionheading]); $this->execute('behat_general::i_press_named_key', ['', 'enter']); } /** * Check that a given question comes after a given section heading in the * quiz navigation block. * * @Then /^I should see question "(?P(?:[^"]|\\")*)" in section "(?P(?:[^"]|\\")*)" in the quiz navigation$/ * @param string $questionnumber the number of the question to check. * @param string $sectionheading which section heading it should appear after. */ public function i_should_see_question_in_section_in_the_quiz_navigation($questionnumber, $sectionheading) { // Using xpath literal to avoid quotes problems. $questionnumberliteral = behat_context_helper::escape($questionnumber); $headingliteral = behat_context_helper::escape($sectionheading); // Split in two checkings to give more feedback in case of exception. $exception = new ExpectationException('Question "' . $questionnumber . '" is not in section "' . $sectionheading . '" in the quiz navigation.', $this->getSession()); $xpath = "//*[@id = 'mod_quiz_navblock']//*[contains(concat(' ', normalize-space(@class), ' '), ' qnbutton ') and " . "contains(., {$questionnumberliteral}) and contains(preceding-sibling::h3[1], {$headingliteral})]"; $this->find('xpath', $xpath, $exception); } /** * Helper used by user_has_attempted_with_responses, * user_has_started_an_attempt_at_quiz_with_details, etc. * * @param TableNode $attemptinfo data table from the Behat step * @return array with two elements, $forcedrandomquestions, $forcedvariants, * that can be passed to $quizgenerator->create_attempt. */ protected function extract_forced_randomisation_from_attempt_info(TableNode $attemptinfo) { global $DB; $forcedrandomquestions = []; $forcedvariants = []; foreach ($attemptinfo->getHash() as $slotinfo) { if (empty($slotinfo['slot'])) { throw new ExpectationException('When simulating a quiz attempt, ' . 'the slot column is required.', $this->getSession()); } if (!empty($slotinfo['actualquestion'])) { $forcedrandomquestions[$slotinfo['slot']] = $DB->get_field('question', 'id', ['name' => $slotinfo['actualquestion']], MUST_EXIST); } if (!empty($slotinfo['variant'])) { $forcedvariants[$slotinfo['slot']] = (int) $slotinfo['variant']; } } return [$forcedrandomquestions, $forcedvariants]; } /** * Helper used by user_has_attempted_with_responses, user_has_checked_answers_in_their_attempt_at_quiz, * user_has_input_answers_in_their_attempt_at_quiz, etc. * * @param TableNode $attemptinfo data table from the Behat step * @return array of responses that can be passed to $quizgenerator->submit_responses. */ protected function extract_responses_from_attempt_info(TableNode $attemptinfo) { $responses = []; foreach ($attemptinfo->getHash() as $slotinfo) { if (empty($slotinfo['slot'])) { throw new ExpectationException('When simulating a quiz attempt, ' . 'the slot column is required.', $this->getSession()); } if (!array_key_exists('response', $slotinfo)) { throw new ExpectationException('When simulating a quiz attempt, ' . 'the response column is required.', $this->getSession()); } $responses[$slotinfo['slot']] = $slotinfo['response']; } return $responses; } /** * Attempt a quiz. * * The first row should be column names: * | slot | actualquestion | variant | response | * The first two of those are required. The others are optional. * * slot The slot * actualquestion This column is optional, and is only needed if the quiz contains * random questions. If so, this will let you control which actual * question gets picked when this slot is 'randomised' at the * start of the attempt. If you don't specify, then one will be picked * at random (which might make the response meaningless). * Give the question name. * variant This column is similar, and also options. It is only needed if * the question that ends up in this slot returns something greater * than 1 for $question->get_num_variants(). Like with actualquestion, * if you specify a value here it is used the fix the 'random' choice * made when the quiz is started. * response The response that was submitted. How this is interpreted depends on * the question type. It gets passed to * {@link core_question_generator::get_simulated_post_data_for_question_attempt()} * and therefore to the un_summarise_response method of the question to decode. * * Then there should be a number of rows of data, one for each question you want to add. * There is no need to supply answers to all questions. If so, other qusetions will be * left unanswered. * * @param string $username the username of the user that will attempt. * @param string $quizname the name of the quiz the user will attempt. * @param TableNode $attemptinfo information about the questions to add, as above. * @param string|null $timefinish if specified, the attempt will be submitted with this * @Given /^user "([^"]*)" has attempted "([^"]*)" with responses:$/ * @Given /^user "([^"]*)" has attempted "([^"]*)" with responses submitting "([^"]*)":$/ */ public function user_has_attempted_with_responses($username, $quizname, TableNode $attemptinfo, $timefinish = null) { global $DB; /** @var mod_quiz_generator $quizgenerator */ $quizgenerator = behat_util::get_data_generator()->get_plugin_generator('mod_quiz'); $quiz = $DB->get_record('quiz', ['name' => $quizname], 'id, timeclose', MUST_EXIST); $quizid = $quiz->id; $timeclose = $quiz->timeclose; // If timefinish is provided, check against timeclose. if ($timefinish !== null) { if ($timeclose && $timefinish > $timeclose) { throw new ExpectationException( "Attempt submission cannot be after the time the quiz closed.", $this->getSession() ); } } $user = $DB->get_record('user', ['username' => $username], '*', MUST_EXIST); list($forcedrandomquestions, $forcedvariants) = $this->extract_forced_randomisation_from_attempt_info($attemptinfo); $responses = $this->extract_responses_from_attempt_info($attemptinfo); $this->set_user($user); $attempt = $quizgenerator->create_attempt($quizid, $user->id, $forcedrandomquestions, $forcedvariants); $quizgenerator->submit_responses($attempt->id, $responses, false, true, $timefinish); $this->set_user(); } /** * Start a quiz attempt without answers. * * @param string $username the username of the user that will attempt. * @param string $quizname the name of the quiz the user will attempt. * @Given /^user "([^"]*)" has started an attempt at quiz "([^"]*)"$/ */ public function user_has_started_an_attempt_at_quiz($username, $quizname) { global $DB; /** @var mod_quiz_generator $quizgenerator */ $quizgenerator = behat_util::get_data_generator()->get_plugin_generator('mod_quiz'); $quizid = $DB->get_field('quiz', 'id', ['name' => $quizname], MUST_EXIST); $user = $DB->get_record('user', ['username' => $username], '*', MUST_EXIST); $this->set_user($user); $quizgenerator->create_attempt($quizid, $user->id); $this->set_user(); } /** * Start a quiz attempt without answers. * * The supplied data table for have a row for each slot where you want * to force either which random question was chose, or which random variant * was used, as for {@link user_has_attempted_with_responses()} above. * * @param string $username the username of the user that will attempt. * @param string $quizname the name of the quiz the user will attempt. * @param TableNode $attemptinfo information about the questions to add, as above. * @Given /^user "([^"]*)" has started an attempt at quiz "([^"]*)" randomised as follows:$/ */ public function user_has_started_an_attempt_at_quiz_with_details($username, $quizname, TableNode $attemptinfo) { global $DB; /** @var mod_quiz_generator $quizgenerator */ $quizgenerator = behat_util::get_data_generator()->get_plugin_generator('mod_quiz'); $quizid = $DB->get_field('quiz', 'id', ['name' => $quizname], MUST_EXIST); $user = $DB->get_record('user', ['username' => $username], '*', MUST_EXIST); list($forcedrandomquestions, $forcedvariants) = $this->extract_forced_randomisation_from_attempt_info($attemptinfo); $this->set_user($user); $quizgenerator->create_attempt($quizid, $user->id, $forcedrandomquestions, $forcedvariants); $this->set_user(); } /** * Input answers to particular questions an existing quiz attempt, without * simulating a click of the 'Check' button, if any. * * Then there should be a number of rows of data, with two columns slot and response, * as for {@link user_has_attempted_with_responses()} above. * There is no need to supply answers to all questions. If so, other questions will be * left unanswered. * * @param string $username the username of the user that will attempt. * @param string $quizname the name of the quiz the user will attempt. * @param TableNode $attemptinfo information about the questions to add, as above. * @throws \Behat\Mink\Exception\ExpectationException * @Given /^user "([^"]*)" has input answers in their attempt at quiz "([^"]*)":$/ */ public function user_has_input_answers_in_their_attempt_at_quiz($username, $quizname, TableNode $attemptinfo) { global $DB; /** @var mod_quiz_generator $quizgenerator */ $quizgenerator = behat_util::get_data_generator()->get_plugin_generator('mod_quiz'); $quizid = $DB->get_field('quiz', 'id', ['name' => $quizname], MUST_EXIST); $user = $DB->get_record('user', ['username' => $username], '*', MUST_EXIST); $responses = $this->extract_responses_from_attempt_info($attemptinfo); $this->set_user($user); $attempts = quiz_get_user_attempts($quizid, $user->id, 'unfinished', true); $quizgenerator->submit_responses(key($attempts), $responses, false, false); $this->set_user(); } /** * Submit answers to questions an existing quiz attempt, with a simulated click on the 'Check' button. * * This step should only be used with question behaviours that have have * a 'Check' button. Those include Interactive with multiple tires, Immediate feedback * and Immediate feedback with CBM. * * Then there should be a number of rows of data, with two columns slot and response, * as for {@link user_has_attempted_with_responses()} above. * There is no need to supply answers to all questions. If so, other questions will be * left unanswered. * * @param string $username the username of the user that will attempt. * @param string $quizname the name of the quiz the user will attempt. * @param TableNode $attemptinfo information about the questions to add, as above. * @throws \Behat\Mink\Exception\ExpectationException * @Given /^user "([^"]*)" has checked answers in their attempt at quiz "([^"]*)":$/ */ public function user_has_checked_answers_in_their_attempt_at_quiz($username, $quizname, TableNode $attemptinfo) { global $DB; /** @var mod_quiz_generator $quizgenerator */ $quizgenerator = behat_util::get_data_generator()->get_plugin_generator('mod_quiz'); $quizid = $DB->get_field('quiz', 'id', ['name' => $quizname], MUST_EXIST); $user = $DB->get_record('user', ['username' => $username], '*', MUST_EXIST); $responses = $this->extract_responses_from_attempt_info($attemptinfo); $this->set_user($user); $attempts = quiz_get_user_attempts($quizid, $user->id, 'unfinished', true); $quizgenerator->submit_responses(key($attempts), $responses, true, false); $this->set_user(); } /** * Finish an existing quiz attempt. * * @param string $username the username of the user that will attempt. * @param string $quizname the name of the quiz the user will attempt. * @Given /^user "([^"]*)" has finished an attempt at quiz "([^"]*)"$/ */ public function user_has_finished_an_attempt_at_quiz($username, $quizname) { global $DB; $quizid = $DB->get_field('quiz', 'id', ['name' => $quizname], MUST_EXIST); $user = $DB->get_record('user', ['username' => $username], '*', MUST_EXIST); $this->set_user($user); $attempts = quiz_get_user_attempts($quizid, $user->id, 'unfinished', true); $attemptobj = quiz_attempt::create(key($attempts)); $attemptobj->process_submit(time(), true); $attemptobj->process_grade_submission(time()); $this->set_user(); } /** * Finish an existing quiz attempt. * * @param string $quizname the name of the quiz the user will attempt. * @param string $username the username of the user that will attempt. * @Given the attempt at :quizname by :username was never submitted */ public function attempt_was_abandoned($quizname, $username) { global $DB; $quizid = $DB->get_field('quiz', 'id', ['name' => $quizname], MUST_EXIST); $user = $DB->get_record('user', ['username' => $username], '*', MUST_EXIST); $this->set_user($user); $attempt = quiz_get_user_attempt_unfinished($quizid, $user->id); if (!$attempt) { throw new coding_exception("No in-progress attempt found for $username and quiz $quizname."); } $attemptobj = quiz_attempt::create($attempt->id); $attemptobj->process_abandon(time(), false); $this->set_user(); } /** * Return a list of the exact named selectors for the component. * * @return behat_component_named_selector[] */ public static function get_exact_named_selectors(): array { return [ new behat_component_named_selector('Edit slot', ["//li[contains(@class,'qtype')]//span[@class='slotnumber' and contains(., %locator%)]/.."]) ]; } /** * Generate pre-created attempts for a quiz. * * @param string $quizname the name of the quiz to create attempts for. * @Given quiz :quizname has pre-created attempts */ public function quiz_has_precreated_attempts(string $quizname): void { global $DB; $quiz = $DB->get_record('quiz', ['name' => $quizname], 'id, course', MUST_EXIST); \mod_quiz\task\precreate_attempts::precreate_attempts_for_quiz($quiz->id, $quiz->course); } } PK9B\S//%tests/behat/completion_manual.featurenu[@mod @mod_quiz @core_completion Feature: Manually complete a quiz In order to meet manual quiz completion requirements As a student I need to be able to view and modify my quiz manual completion status Background: Given the following "users" exist: | username | firstname | lastname | email | | student1 | Student | 1 | student1@example.com | | teacher1 | Teacher | 1 | teacher1@example.com | And the following "courses" exist: | fullname | shortname | category | enablecompletion | | Course 1 | C1 | 0 | 1 | And the following "course enrolments" exist: | user | course | role | | teacher1 | C1 | editingteacher | | student1 | C1 | student | And the following "activities" exist: | activity | name | course | idnumber | completion | | quiz | Test quiz name | C1 | quiz1 | 1 | 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 | And quiz "Test quiz name" contains the following questions: | question | page | | First question | 1 | @javascript Scenario: A student can manually mark the quiz activity as done but a teacher cannot Given I am on the "Test quiz name" "quiz activity" page logged in as teacher1 And the manual completion button for "Test quiz name" should be disabled And I log out # Student view. When I am on the "Test quiz name" "quiz activity" page logged in as student1 Then the manual completion button of "Test quiz name" is displayed as "Mark as done" And I toggle the manual completion state of "Test quiz name" And the manual completion button of "Test quiz name" is displayed as "Done" PK9B\\ғ]tests/behat/preview.featurenu[@mod @mod_quiz Feature: Preview a quiz as a teacher In order to verify my quizzes are ready for my students As a teacher I need to be able to preview them Background: Given the following "users" exist: | username | firstname | lastname | email | | teacher | Teacher | One | teacher@example.com | 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 "activities" exist: | activity | name | intro | course | idnumber | | quiz | Quiz 1 | Quiz 1 description | 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 | TF1 | First question | | Test questions | truefalse | TF2 | Second question | And quiz "Quiz 1" contains the following questions: | question | page | maxmark | | TF1 | 1 | | | TF2 | 1 | 3.0 | And user "teacher" has attempted "Quiz 1" with responses: | slot | response | | 1 | True | | 2 | False | @javascript Scenario: Review the quiz attempt When I am on the "Quiz 1" "mod_quiz > View" page logged in as "teacher" And I follow "Review" And "Attempt 1 summary" "table" should exist Then I should see "25.00 out of 100.00" And I should see "v1 (latest)" in the "Question 1" "question" And I follow "Finish review" And "Review" "link" in the "Attempt 1" "list_item" should be visible And "Attempt 1 summary" "table" should exist @javascript Scenario: Review the quiz attempt with custom decimal separator Given the following "language customisations" exist: | component | stringid | value | | core_langconfig | decsep | # | When I am on the "Quiz 1" "mod_quiz > View" page logged in as "teacher" And I follow "Review" Then I should see "1#00/4#00" And I should see "25#00 out of 100#00" And I should see "Mark 1#00 out of 1#00" And I follow "Finish review" And "Review" "link" in the "Attempt 1" "list_item" should be visible Scenario: Preview the quiz Given I am on the "Quiz 1" "mod_quiz > View" page logged in as "teacher" When I press "Preview quiz" Then I should see "Question 1" And I should see "v1 (latest)" in the "Question 1" "question" And "Start a new preview" "button" should exist Scenario: Teachers should see a notice if the quiz is not available to students Given the following "activities" exist: | activity | name | course | timeclose | | quiz | Quiz 2 | C1 | ##yesterday## | And quiz "Quiz 2" contains the following questions: | question | page | maxmark | | TF1 | 1 | | | TF2 | 1 | 3.0 | When I am on the "Quiz 2" "mod_quiz > View" page logged in as "admin" And I should see "This quiz is currently not available." And I press "Preview quiz" Then I should see "if this were a real attempt, you would be blocked" in the ".alert-warning" "css_element" Scenario: Admins should be able to preview a quiz Given I am on the "Quiz 1" "mod_quiz > View" page logged in as "admin" When I press "Preview quiz" Then I should see "Question 1" And "Start a new preview" "button" should exist @javascript Scenario: Teacher responses should be cleared after updating the question too much in the preview. Given the following "activities" exist: | activity | name | course | | quiz | Quiz 3 | C1 | And the following "questions" exist: | questioncategory | qtype | name | questiontext | | Test questions | multichoice | Multi-choice-002 | one_of_four | And quiz "Quiz 3" contains the following questions: | question | page | | Multi-choice-002 | 1 | When I am on the "Quiz 3" "mod_quiz > View" page logged in as "teacher" And I press "Preview quiz" And I should see "one_of_four" And I should see "v1 (latest)" And I click on "One" "qtype_multichoice > Answer" And I click on "Two" "qtype_multichoice > Answer" And I press "Finish attempt ..." And I press "Return to attempt" And I click on "Edit question" "link" in the "Question 1" "question" And I set the field "Question text" to "one_of_four version 2" And I set the field "Choice 4" to "" And I press "id_submitbutton" Then I should see "one_of_four version 2" And I should see "v2 (latest)" And I should see "One" And I should see "Two" And I should see "Three" And I should not see "Four" And "input[type=checkbox][name$=choice0]:checked" "css_element" should not exist And "input[type=checkbox][name$=choice1]:checked" "css_element" should not exist And "input[type=checkbox][name$=choice2]:checked" "css_element" should not exist PK9B\wC44&tests/behat/editing_add_random.featurenu[@mod @mod_quiz @javascript Feature: Adding random questions to a quiz based on category and tags In order to have better assessment As a teacher I want to display questions that are randomly picked from the question bank Background: Given the following "users" exist: | username | firstname | lastname | email | | teacher1 | Teacher | 1 | t1@example.com | And the following "courses" exist: | fullname | shortname | | Course 1 | C1 | And the following "course enrolments" exist: | user | course | role | | teacher1 | C1 | editingteacher | And the following "activities" exist: | activity | name | intro | course | idnumber | | quiz | Quiz 1 | Quiz 1 for testing the Add random question form | C1 | quiz1 | | qbank | Qbank 1 | Question bank 1 for testing the Add menu | C1 | qbank1 | And the following "question categories" exist: | contextlevel | reference | name | | Activity module | quiz1 | Questions Category 1 | | Activity module | quiz1 | Questions Category 2 | | Activity module | qbank1 | Qbank questions | And the following "question categories" exist: | contextlevel | reference | name | questioncategory | | Activity module | quiz1 | Subcategory | Questions Category 1 | And the following "questions" exist: | questioncategory | qtype | name | user | questiontext | | Questions Category 1 | essay | question 1 name | admin | Question 1 text | | Questions Category 1 | essay | question 2 name | teacher1 | Question 2 text | | Subcategory | essay | question 3 name | teacher1 | Question 3 text | | Subcategory | essay | question 4 name | teacher1 | Question 4 text | | Questions Category 1 | essay | "listen" & "answer" | teacher1 | Question 5 text | | Qbank questions | essay | Qbank question 1 | teacher1 | Qbank question | And the following "core_question > Tags" exist: | question | tag | | question 1 name | foo | | question 2 name | bar | | question 3 name | foo | | question 4 name | bar | | "listen" & "answer" | foo | | Qbank question 1 | qbanktag | Scenario: Available tags are shown in the autocomplete tag field Given I am on the "Quiz 1" "mod_quiz > Edit" page logged in as "teacher1" When I open the "last" add to quiz menu And I follow "a random question" And I add question bank filter "Tag" And I click on "Tag" "field" And I press the down key Then "foo" "autocomplete_suggestions" should exist And "bar" "autocomplete_suggestions" should exist Scenario: Questions can be filtered by tags Given I am on the "Quiz 1" "mod_quiz > Edit" page logged in as "teacher1" When I open the "last" add to quiz menu And I follow "a random question" And I apply question bank filter "Category" with value "Questions Category 1" And I apply question bank filter "Tag" with value "foo" And I wait until the page is ready And I should see "question 1 name" And I should see "\"listen\" & \"answer\"" And I should not see "question 2 name" And I should not see "question 3 name" And I should not see "question 4 name" # Ensure tagged questions inside subcategories are also matched. And I set the field "Also show questions from subcategories" to "1" And I click on "Apply filters" "button" And I wait until the page is ready And I should see "question 1 name" And I should see "question 3 name" And I should see "\"listen\" & \"answer\"" And I should not see "question 2 name" And I should not see "question 4 name" Scenario: Questions can be filtered by tags on a shared question bank Given I am on the "Quiz 1" "mod_quiz > Edit" page logged in as "teacher1" When I open the "last" add to quiz menu And I follow "a random question" Then 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 "Qbank questions" And I apply question bank filter "Tag" with value "qbanktag" And I click on "Apply filters" "button" And I wait until the page is ready And I should see "Qbank question 1" And I should not see "question 3 name" And I should not see "question 2 name" And I should not see "question 4 name" Scenario: A random question can be added to the quiz Given I am on the "Quiz 1" "mod_quiz > Edit" page logged in as "teacher1" And I open the "last" add to quiz menu And I follow "a random question" And I apply question bank filter "Category" with value "Questions Category 1" And I apply question bank filter "Tag" with value "foo" And I select "1" from the "randomcount" singleselect And I press "Add random question" And I should see "Random (Questions Category 1) based on filter condition with tags: foo" on quiz page "1" When I click on "Configure question" "link" in the "Random (Questions Category 1) based on filter condition with tags: foo" "list_item" Then I should see "Questions Category 1" And I should see "foo" And I should see "question 1 name" And I should see "\"listen\" & \"answer\"" And I click on "Cancel" "button" in the "Editing a random question" "dialogue" # Include subcategories. And I navigate to "Questions" in current page administration And I open the "Page 1" add to quiz menu And I follow "a random question" And I apply question bank filter "Category" with value "Questions Category 1" And I set the field "Also show questions from subcategories" to "1" And I click on "Apply filters" "button" And I apply question bank filter "Tag" with value "foo" And I select "1" from the "randomcount" singleselect And I press "Add random question" And I should see "Random (Questions Category 1 and subcategories) based on filter condition with tags: foo" on quiz page "1" And I click on "Configure question" "link" in the "Random (Questions Category 1 and subcategories) based on filter condition with tags: foo" "list_item" And I should see "Questions Category 1" And I should see "foo" And I should see "question 1 name" And I should see "\"listen\" & \"answer\"" And I should see "question 3 name" Scenario: A random question from the quiz's top category can be added to the quiz Given the following "question categories" exist: | contextlevel | reference | name | | Activity module | quiz1 | Quiz 1 category | And the following "questions" exist: | questioncategory | qtype | name | user | questiontext | | Quiz 1 category | essay | quiz 1 question 1 name | teacher1 | Quiz 1 question 1 text | | Quiz 1 category | essay | quiz 1 question 2 name | teacher1 | Quiz 1 question 2 text | And the following "core_question > Tags" exist: | question | tag | | quiz 1 question 1 name | foo | And I am on the "Quiz 1" "mod_quiz > Edit" page logged in as "teacher1" And I open the "last" add to quiz menu And I follow "a random question" And I apply question bank filter "Category" with value "Top for Quiz 1" And I set the field "Also show questions from subcategories" to "1" And I click on "Apply filters" "button" And I apply question bank filter "Tag" with value "foo" And I select "1" from the "randomcount" singleselect When I press "Add random question" Then I should see "Random (Any category of this quiz) based on filter condition with tags: foo" on quiz page "1" And I click on "Configure question" "link" in the "Random (Any category of this quiz) based on filter condition with tags: foo" "list_item" And I should see "Top for Quiz 1" And I should see "foo" And I should see "quiz 1 question 1 name" And I should not see "quiz 1 question 2 name" Scenario: A random question from a top category, excluding subcategories, shows an indicator of being faulty Given I am on the "Quiz 1" "mod_quiz > Edit" page logged in as "teacher1" And I open the "last" add to quiz menu And I follow "a random question" And I apply question bank filter "Category" with value "Top for Quiz 1" And I set the field "Also show questions from subcategories" to "0" And I click on "Apply filters" "button" And I apply question bank filter "Tag" with value "foo" And I select "1" from the "randomcount" singleselect When I press "Add random question" Then I should see "Random (Faulty question) based on filter condition" on quiz page "1" Scenario: After closing and reopening the modal, it still works When I am on the "Quiz 1" "mod_quiz > Edit" page logged in as teacher1 And I open the "last" add to quiz menu And I follow "a random question" And I click on "Close" "button" in the "Add a random question at the end" "dialogue" And I open the "last" add to quiz menu And I follow "a random question" And I should not see "question 3 name" And I set the field "Category" to "Subcategory" And I press "Apply filters" Then I should see "question 3 name" Scenario: A random question can be added to the quiz from a shared question bank Given I am on the "Quiz 1" "mod_quiz > Edit" page logged in as "teacher1" And I open the "last" add to quiz menu And I follow "a random question" And I should see "Current bank: Quiz 1" And I apply question bank filter "Category" with value "Questions Category 1" And I should see "question 1 name" And I click on "Switch bank" "button" And I click on "Qbank 1" "link" in the "Select question bank" "dialogue" And I should see "Current bank: Qbank 1" And I should not see "question 1 name" And I apply question bank filter "Category" with value "Qbank questions" And I should see "Qbank question 1" When I apply question bank filter "Tag" with value "qbanktag" And I select "1" from the "randomcount" singleselect And I press "Add random question" Then I should see "Random (Qbank questions) based on filter condition with tags: qbanktag" on quiz page "1" And I click on "Configure question" "link" in the "Random (Qbank questions) based on filter condition with tags: qbank" "list_item" And I should see "Qbank questions" And I should see "qbanktag" And I should see "Qbank question 1" Scenario: Teacher without moodle/question:useall should not see the add a random question menu item Given the following "permission overrides" exist: | capability | permission | role | contextlevel | reference | | moodle/question:useall | Prevent | editingteacher | Course | C1 | And I log in as "teacher1" And I am on the "Quiz 1" "mod_quiz > Edit" page When I open the "last" add to quiz menu Then I should not see "a random question" Scenario: A random question can be added to the quiz by creating a new category Given I am on the "Quiz 1" "mod_quiz > Edit" page logged in as "teacher1" When I open the "last" add to quiz menu And I follow "a random question" And I follow "New category" And "Help with Parent category" "icon" should exist in the "Random question using a new category" "fieldset" And I set the following fields to these values: | Name | New Random category | | Parent category | Questions Category 1 | And I press "Create category and add random question" And I should see "Random (New Random category) based on filter condition" on quiz page "1" And I click on "Configure question" "link" in the "Random (New Random category) based on filter condition" "list_item" Then I should see "New Random category" Scenario: See questions link applies all random question filters Given I am on the "Quiz 1" "mod_quiz > Edit" page logged in as "teacher1" And I open the "last" add to quiz menu And I follow "a random question" And I apply question bank filter "Category" with value "Questions Category 1" And I apply question bank filter "Tag" with value "foo" And I select "1" from the "randomcount" singleselect And I press "Add random question" And I should see "Random (Questions Category 1) based on filter condition with tags: foo" on quiz page "1" And I click on "Configure question" "link" in the "Random (Questions Category 1) based on filter condition with tags: foo" "list_item" And I should see "Questions Category 1" And I should see "foo" And I should see "question 1 name" And I should see "\"listen\" & \"answer\"" And I should not see "bar" And I should not see "question 2 name" When I am on the "Quiz 1" "mod_quiz > Edit" page And I click on "(See questions)" "link" in the "Random (Questions Category 1) based on filter condition with tags: foo" "list_item" Then I should see "Questions Category 1" And I should see "foo" And I should see "question 1 name" And I should see "\"listen\" & \"answer\"" And I should not see "bar" And I should not see "question 2 name" PK9B\-HIItests/restore_attempt_test.phpnu[. namespace mod_quiz; use backup; use core_user; use restore_controller; use restore_dbops; /** * Unit tests restoring quiz attempts * * @package mod_quiz * @copyright 2021 Paul Holden * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class restore_attempt_test extends \advanced_testcase { /** * Load required libraries */ public static function setUpBeforeClass(): void { global $CFG; require_once("{$CFG->dirroot}/backup/util/includes/restore_includes.php"); parent::setUpBeforeClass(); } /** * Test restore dates. * * @covers \restore_quiz_activity_structure_step */ public function test_restore_question_attempts_missing_users(): void { global $DB, $USER; $this->resetAfterTest(); $this->setAdminUser(); // Contains a quiz with four attempts from different users. The users are as follows (user ID -> user): // 3 -> User 01, 4 -> User 02, 5 -> User 03, 6 -> User 04. // The user details for User 02 and User 03 have been removed from the backup file. $testfixture = self::get_fixture_path(__NAMESPACE__, 'question_attempts_missing_users.mbz'); // Extract our test fixture, ready to be restored. $backuptempdir = 'aaa'; $backuppath = make_backup_temp_directory($backuptempdir); get_file_packer('application/vnd.moodle.backup')->extract_to_pathname($testfixture, $backuppath); // Do the restore to new course with default settings. $categoryid = $DB->get_field('course_categories', 'MIN(id)', []); $courseid = restore_dbops::create_new_course('Test fullname', 'Test shortname', $categoryid); $controller = new restore_controller($backuptempdir, $courseid, backup::INTERACTIVE_NO, backup::MODE_GENERAL, $USER->id, backup::TARGET_NEW_COURSE); $controller->execute_precheck(); $results = $controller->get_precheck_results(); // Backup contains categories attached to deprecated contexts so the results should only contain warnings for these. $this->assertCount(2, $results['warnings']); foreach ($results['warnings'] as $warning) { $this->assertStringContainsString('will be created at a question bank module context by restore', $warning); } $this->assertArrayNotHasKey('errors', $results); $controller->execute_plan(); $controller->destroy(); // Grade restore also generates some debugging. $this->assertDebuggingCalledCount(2); $restoredquiz = $DB->get_record('quiz', []); // Assert logs were added for User 02 and User 03, due to missing mapping lookup. $loginfomessages = $DB->get_fieldset_select('backup_logs', 'message', 'backupid = ? AND loglevel = ?', [ $controller->get_restoreid(), backup::LOG_INFO, ]); $this->assertContains("Mapped user ID not found for user 4, quiz {$restoredquiz->id}, attempt 1. Skipping quiz attempt", $loginfomessages); $this->assertContains("Mapped user ID not found for user 5, quiz {$restoredquiz->id}, attempt 1. Skipping quiz attempt", $loginfomessages); // User 01 has supplied the wrong answer, assert dates match the backup file too. $user01attempt = $DB->get_record('quiz_attempts', [ 'quiz' => $restoredquiz->id, 'userid' => core_user::get_user_by_username('user01')->id, ]); $this->assertEquals(1634751274, $user01attempt->timestart); $this->assertEquals(1634751290, $user01attempt->timefinish); $this->assertEquals(0.0, (float) $user01attempt->sumgrades); // User 04 has supplied the correct answer, assert dates match the backup file too. $user04attempt = $DB->get_record('quiz_attempts', [ 'quiz' => $restoredquiz->id, 'userid' => core_user::get_user_by_username('user04')->id, ]); $this->assertEquals(1634751341, $user04attempt->timestart); $this->assertEquals(1634751347, $user04attempt->timefinish); $this->assertEquals(1.0, (float) $user04attempt->sumgrades); } } PK9B\Yltests/structure_test.phpnu[. namespace mod_quiz; use core\exception\coding_exception; use question_bank; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->dirroot . '/mod/quiz/tests/quiz_question_helper_test_trait.php'); /** * Unit tests for quiz events. * * @package mod_quiz * @category test * @copyright 2013 Adrian Greeve * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @covers \mod_quiz\structure */ final class structure_test extends \advanced_testcase { use \quiz_question_helper_test_trait; /** * Verify that the given layout matches that expected. * @param array $expectedlayout as for $layout in {@link create_test_quiz()}. * @param structure $structure the structure to test. */ protected function assert_quiz_layout($expectedlayout, structure $structure) { $sections = $structure->get_sections(); $slot = 1; foreach ($expectedlayout as $item) { if (is_string($item)) { $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz'); [$heading, $shuffle] = $quizgenerator->parse_section_name($item); $section = array_shift($sections); if ($slot > 1 && $section->heading == '' && $section->firstslot == 1) { // The array $expectedlayout did not contain default first quiz section, so skip over it. $section = array_shift($sections); } $this->assertEquals($slot, $section->firstslot); $this->assertEquals($heading, $section->heading); $this->assertEquals($shuffle, $section->shufflequestions); } else { list($name, $page, $qtype) = $item; $question = $structure->get_question_in_slot($slot); $this->assertEquals($name, $question->name); $this->assertEquals($slot, $question->slot, 'Slot number wrong for question ' . $name); $this->assertEquals($qtype, $question->qtype, 'Question type wrong for question ' . $name); $this->assertEquals($page, $question->page, 'Page number wrong for question ' . $name); $slot += 1; } } if ($slot - 1 != count($structure->get_slots())) { $this->fail('The quiz contains more slots than expected.'); } if (!empty($sections)) { $section = array_shift($sections); if ($section->heading != '' || $section->firstslot != 1) { $this->fail('Unexpected section (' . $section->heading .') found in the quiz.'); } } } public function test_get_quiz_slots(): void { $this->resetAfterTest(); $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz'); $quizobj = $quizgenerator->create_test_quiz([ ['TF1', 1, 'truefalse'], ['TF2', 1, 'truefalse'], ]); $structure = structure::create_for_quiz($quizobj); // Are the correct slots returned? $slots = $structure->get_slots(); $this->assertCount(2, $structure->get_slots()); } public function test_quiz_has_one_section_by_default(): void { $this->resetAfterTest(); $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz'); $quizobj = $quizgenerator->create_test_quiz([ ['TF1', 1, 'truefalse'], ]); $structure = structure::create_for_quiz($quizobj); $sections = $structure->get_sections(); $this->assertCount(1, $sections); $section = array_shift($sections); $this->assertEquals(1, $section->firstslot); $this->assertEquals('', $section->heading); $this->assertEquals(0, $section->shufflequestions); } public function test_get_sections(): void { $this->resetAfterTest(); $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz'); $quizobj = $quizgenerator->create_test_quiz([ 'Heading 1*', ['TF1', 1, 'truefalse'], 'Heading 2*', ['TF2', 2, 'truefalse'], ]); $structure = structure::create_for_quiz($quizobj); $sections = $structure->get_sections(); $this->assertCount(2, $sections); $section = array_shift($sections); $this->assertEquals(1, $section->firstslot); $this->assertEquals('Heading 1', $section->heading); $this->assertEquals(1, $section->shufflequestions); $section = array_shift($sections); $this->assertEquals(2, $section->firstslot); $this->assertEquals('Heading 2', $section->heading); $this->assertEquals(1, $section->shufflequestions); } public function test_remove_section_heading(): void { $this->resetAfterTest(); $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz'); $quizobj = $quizgenerator->create_test_quiz([ 'Heading 1', ['TF1', 1, 'truefalse'], 'Heading 2', ['TF2', 2, 'truefalse'], ]); $structure = structure::create_for_quiz($quizobj); $sections = $structure->get_sections(); $section = end($sections); $structure->remove_section_heading($section->id); $structure = structure::create_for_quiz($quizobj); $this->assert_quiz_layout([ 'Heading 1', ['TF1', 1, 'truefalse'], ['TF2', 2, 'truefalse'], ], $structure); } public function test_cannot_remove_first_section(): void { $this->resetAfterTest(); $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz'); $quizobj = $quizgenerator->create_test_quiz([ 'Heading 1', ['TF1', 1, 'truefalse'], ]); $structure = structure::create_for_quiz($quizobj); $sections = $structure->get_sections(); $section = reset($sections); $this->expectException(\coding_exception::class); $structure->remove_section_heading($section->id); } public function test_move_slot_to_the_same_place_does_nothing(): void { $this->resetAfterTest(); $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz'); $quizobj = $quizgenerator->create_test_quiz([ ['TF1', 1, 'truefalse'], ['TF2', 1, 'truefalse'], ['TF3', 2, 'truefalse'], ]); $structure = structure::create_for_quiz($quizobj); $idtomove = $structure->get_question_in_slot(2)->slotid; $idmoveafter = $structure->get_question_in_slot(1)->slotid; $structure->move_slot($idtomove, $idmoveafter, '1'); $structure = structure::create_for_quiz($quizobj); $this->assert_quiz_layout([ ['TF1', 1, 'truefalse'], ['TF2', 1, 'truefalse'], ['TF3', 2, 'truefalse'], ], $structure); } public function test_move_slot_end_of_one_page_to_start_of_next(): void { $this->resetAfterTest(); $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz'); $quizobj = $quizgenerator->create_test_quiz([ ['TF1', 1, 'truefalse'], ['TF2', 1, 'truefalse'], ['TF3', 2, 'truefalse'], ]); $structure = structure::create_for_quiz($quizobj); $idtomove = $structure->get_question_in_slot(2)->slotid; $idmoveafter = $structure->get_question_in_slot(2)->slotid; $structure->move_slot($idtomove, $idmoveafter, '2'); $structure = structure::create_for_quiz($quizobj); $this->assert_quiz_layout([ ['TF1', 1, 'truefalse'], ['TF2', 2, 'truefalse'], ['TF3', 2, 'truefalse'], ], $structure); } public function test_move_last_slot_to_previous_page_emptying_the_last_page(): void { $this->resetAfterTest(); $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz'); $quizobj = $quizgenerator->create_test_quiz([ ['TF1', 1, 'truefalse'], ['TF2', 2, 'truefalse'], ]); $structure = structure::create_for_quiz($quizobj); $idtomove = $structure->get_question_in_slot(2)->slotid; $idmoveafter = $structure->get_question_in_slot(1)->slotid; $structure->move_slot($idtomove, $idmoveafter, '1'); $structure = structure::create_for_quiz($quizobj); $this->assert_quiz_layout([ ['TF1', 1, 'truefalse'], ['TF2', 1, 'truefalse'], ], $structure); } public function test_end_of_one_section_to_start_of_next(): void { $this->resetAfterTest(); $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz'); $quizobj = $quizgenerator->create_test_quiz([ ['TF1', 1, 'truefalse'], ['TF2', 1, 'truefalse'], 'Heading', ['TF3', 2, 'truefalse'], ]); $structure = structure::create_for_quiz($quizobj); $idtomove = $structure->get_question_in_slot(2)->slotid; $idmoveafter = $structure->get_question_in_slot(2)->slotid; $structure->move_slot($idtomove, $idmoveafter, '2'); $structure = structure::create_for_quiz($quizobj); $this->assert_quiz_layout([ ['TF1', 1, 'truefalse'], 'Heading', ['TF2', 2, 'truefalse'], ['TF3', 2, 'truefalse'], ], $structure); } public function test_start_of_one_section_to_end_of_previous(): void { $this->resetAfterTest(); $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz'); $quizobj = $quizgenerator->create_test_quiz([ ['TF1', 1, 'truefalse'], 'Heading', ['TF2', 2, 'truefalse'], ['TF3', 2, 'truefalse'], ]); $structure = structure::create_for_quiz($quizobj); $idtomove = $structure->get_question_in_slot(2)->slotid; $idmoveafter = $structure->get_question_in_slot(1)->slotid; $structure->move_slot($idtomove, $idmoveafter, '1'); $structure = structure::create_for_quiz($quizobj); $this->assert_quiz_layout([ ['TF1', 1, 'truefalse'], ['TF2', 1, 'truefalse'], 'Heading', ['TF3', 2, 'truefalse'], ], $structure); } public function test_move_slot_on_same_page(): void { $this->resetAfterTest(); $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz'); $quizobj = $quizgenerator->create_test_quiz([ ['TF1', 1, 'truefalse'], ['TF2', 1, 'truefalse'], ['TF3', 1, 'truefalse'], ]); $structure = structure::create_for_quiz($quizobj); $idtomove = $structure->get_question_in_slot(2)->slotid; $idmoveafter = $structure->get_question_in_slot(3)->slotid; $structure->move_slot($idtomove, $idmoveafter, '1'); $structure = structure::create_for_quiz($quizobj); $this->assert_quiz_layout([ ['TF1', 1, 'truefalse'], ['TF3', 1, 'truefalse'], ['TF2', 1, 'truefalse'], ], $structure); } public function test_move_slot_up_onto_previous_page(): void { $this->resetAfterTest(); $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz'); $quizobj = $quizgenerator->create_test_quiz([ ['TF1', 1, 'truefalse'], ['TF2', 2, 'truefalse'], ['TF3', 2, 'truefalse'], ]); $structure = structure::create_for_quiz($quizobj); $idtomove = $structure->get_question_in_slot(3)->slotid; $idmoveafter = $structure->get_question_in_slot(1)->slotid; $structure->move_slot($idtomove, $idmoveafter, '1'); $structure = structure::create_for_quiz($quizobj); $this->assert_quiz_layout([ ['TF1', 1, 'truefalse'], ['TF3', 1, 'truefalse'], ['TF2', 2, 'truefalse'], ], $structure); } public function test_move_slot_emptying_a_page_renumbers_pages(): void { $this->resetAfterTest(); $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz'); $quizobj = $quizgenerator->create_test_quiz([ ['TF1', 1, 'truefalse'], ['TF2', 2, 'truefalse'], ['TF3', 3, 'truefalse'], ]); $structure = structure::create_for_quiz($quizobj); $idtomove = $structure->get_question_in_slot(2)->slotid; $idmoveafter = $structure->get_question_in_slot(3)->slotid; $structure->move_slot($idtomove, $idmoveafter, '3'); $structure = structure::create_for_quiz($quizobj); $this->assert_quiz_layout([ ['TF1', 1, 'truefalse'], ['TF3', 2, 'truefalse'], ['TF2', 2, 'truefalse'], ], $structure); } public function test_move_slot_too_small_page_number_detected(): void { $this->resetAfterTest(); $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz'); $quizobj = $quizgenerator->create_test_quiz([ ['TF1', 1, 'truefalse'], ['TF2', 2, 'truefalse'], ['TF3', 3, 'truefalse'], ]); $structure = structure::create_for_quiz($quizobj); $idtomove = $structure->get_question_in_slot(3)->slotid; $idmoveafter = $structure->get_question_in_slot(2)->slotid; $this->expectException(\coding_exception::class); $structure->move_slot($idtomove, $idmoveafter, '1'); } public function test_move_slot_too_large_page_number_detected(): void { $this->resetAfterTest(); $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz'); $quizobj = $quizgenerator->create_test_quiz([ ['TF1', 1, 'truefalse'], ['TF2', 2, 'truefalse'], ['TF3', 3, 'truefalse'], ]); $structure = structure::create_for_quiz($quizobj); $idtomove = $structure->get_question_in_slot(1)->slotid; $idmoveafter = $structure->get_question_in_slot(2)->slotid; $this->expectException(\coding_exception::class); $structure->move_slot($idtomove, $idmoveafter, '4'); } public function test_move_slot_within_section(): void { $this->resetAfterTest(); $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz'); $quizobj = $quizgenerator->create_test_quiz([ 'Heading 1', ['TF1', 1, 'truefalse'], ['TF2', 1, 'truefalse'], 'Heading 2', ['TF3', 2, 'truefalse'], ]); $structure = structure::create_for_quiz($quizobj); $idtomove = $structure->get_question_in_slot(1)->slotid; $idmoveafter = $structure->get_question_in_slot(2)->slotid; $structure->move_slot($idtomove, $idmoveafter, '1'); $structure = structure::create_for_quiz($quizobj); $this->assert_quiz_layout([ 'Heading 1', ['TF2', 1, 'truefalse'], ['TF1', 1, 'truefalse'], 'Heading 2', ['TF3', 2, 'truefalse'], ], $structure); } public function test_move_slot_to_new_section(): void { $this->resetAfterTest(); $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz'); $quizobj = $quizgenerator->create_test_quiz([ 'Heading 1', ['TF1', 1, 'truefalse'], ['TF2', 1, 'truefalse'], 'Heading 2', ['TF3', 2, 'truefalse'], ]); $structure = structure::create_for_quiz($quizobj); $idtomove = $structure->get_question_in_slot(2)->slotid; $idmoveafter = $structure->get_question_in_slot(3)->slotid; $structure->move_slot($idtomove, $idmoveafter, '2'); $structure = structure::create_for_quiz($quizobj); $this->assert_quiz_layout([ 'Heading 1', ['TF1', 1, 'truefalse'], 'Heading 2', ['TF3', 2, 'truefalse'], ['TF2', 2, 'truefalse'], ], $structure); } public function test_move_slot_to_start(): void { $this->resetAfterTest(); $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz'); $quizobj = $quizgenerator->create_test_quiz([ 'Heading 1', ['TF1', 1, 'truefalse'], 'Heading 2', ['TF2', 2, 'truefalse'], ['TF3', 2, 'truefalse'], ]); $structure = structure::create_for_quiz($quizobj); $idtomove = $structure->get_question_in_slot(3)->slotid; $structure->move_slot($idtomove, 0, '1'); $structure = structure::create_for_quiz($quizobj); $this->assert_quiz_layout([ 'Heading 1', ['TF3', 1, 'truefalse'], ['TF1', 1, 'truefalse'], 'Heading 2', ['TF2', 2, 'truefalse'], ], $structure); } public function test_move_slot_down_to_start_of_second_section(): void { $this->resetAfterTest(); $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz'); $quizobj = $quizgenerator->create_test_quiz([ 'Heading 1', ['TF1', 1, 'truefalse'], ['TF2', 1, 'truefalse'], 'Heading 2', ['TF3', 2, 'truefalse'], ]); $structure = structure::create_for_quiz($quizobj); $idtomove = $structure->get_question_in_slot(2)->slotid; $idmoveafter = $structure->get_question_in_slot(2)->slotid; $structure->move_slot($idtomove, $idmoveafter, '2'); $structure = structure::create_for_quiz($quizobj); $this->assert_quiz_layout([ 'Heading 1', ['TF1', 1, 'truefalse'], 'Heading 2', ['TF2', 2, 'truefalse'], ['TF3', 2, 'truefalse'], ], $structure); } public function test_move_first_slot_down_to_start_of_page_2(): void { $this->resetAfterTest(); $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz'); $quizobj = $quizgenerator->create_test_quiz([ 'Heading 1', ['TF1', 1, 'truefalse'], ['TF2', 2, 'truefalse'], ]); $structure = structure::create_for_quiz($quizobj); $idtomove = $structure->get_question_in_slot(1)->slotid; $structure->move_slot($idtomove, 0, '2'); $structure = structure::create_for_quiz($quizobj); $this->assert_quiz_layout([ 'Heading 1', ['TF1', 1, 'truefalse'], ['TF2', 1, 'truefalse'], ], $structure); } public function test_move_first_slot_to_same_place_on_page_1(): void { $this->resetAfterTest(); $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz'); $quizobj = $quizgenerator->create_test_quiz([ 'Heading 1', ['TF1', 1, 'truefalse'], ['TF2', 2, 'truefalse'], ]); $structure = structure::create_for_quiz($quizobj); $idtomove = $structure->get_question_in_slot(1)->slotid; $structure->move_slot($idtomove, 0, '1'); $structure = structure::create_for_quiz($quizobj); $this->assert_quiz_layout([ 'Heading 1', ['TF1', 1, 'truefalse'], ['TF2', 2, 'truefalse'], ], $structure); } public function test_move_first_slot_to_before_page_1(): void { $this->resetAfterTest(); $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz'); $quizobj = $quizgenerator->create_test_quiz([ 'Heading 1', ['TF1', 1, 'truefalse'], ['TF2', 2, 'truefalse'], ]); $structure = structure::create_for_quiz($quizobj); $idtomove = $structure->get_question_in_slot(1)->slotid; $structure->move_slot($idtomove, 0, ''); $structure = structure::create_for_quiz($quizobj); $this->assert_quiz_layout([ 'Heading 1', ['TF1', 1, 'truefalse'], ['TF2', 2, 'truefalse'], ], $structure); } public function test_move_slot_up_to_start_of_second_section(): void { $this->resetAfterTest(); $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz'); $quizobj = $quizgenerator->create_test_quiz([ 'Heading 1', ['TF1', 1, 'truefalse'], 'Heading 2', ['TF2', 2, 'truefalse'], 'Heading 3', ['TF3', 3, 'truefalse'], ['TF4', 3, 'truefalse'], ]); $structure = structure::create_for_quiz($quizobj); $idtomove = $structure->get_question_in_slot(3)->slotid; $idmoveafter = $structure->get_question_in_slot(1)->slotid; $structure->move_slot($idtomove, $idmoveafter, '2'); $structure = structure::create_for_quiz($quizobj); $this->assert_quiz_layout([ 'Heading 1', ['TF1', 1, 'truefalse'], 'Heading 2', ['TF3', 2, 'truefalse'], ['TF2', 2, 'truefalse'], 'Heading 3', ['TF4', 3, 'truefalse'], ], $structure); } public function test_move_slot_does_not_violate_heading_unique_key(): void { $this->resetAfterTest(); $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz'); $quizobj = $quizgenerator->create_test_quiz([ 'Heading 1', ['TF1', 1, 'truefalse'], 'Heading 2', ['TF2', 2, 'truefalse'], 'Heading 3', ['TF3', 3, 'truefalse'], ['TF4', 3, 'truefalse'], ]); $structure = structure::create_for_quiz($quizobj); $idtomove = $structure->get_question_in_slot(4)->slotid; $idmoveafter = $structure->get_question_in_slot(1)->slotid; $structure->move_slot($idtomove, $idmoveafter, 1); $structure = structure::create_for_quiz($quizobj); $this->assert_quiz_layout([ 'Heading 1', ['TF1', 1, 'truefalse'], ['TF4', 1, 'truefalse'], 'Heading 2', ['TF2', 2, 'truefalse'], 'Heading 3', ['TF3', 3, 'truefalse'], ], $structure); } public function test_quiz_remove_slot(): void { $this->resetAfterTest(); $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz'); $quizobj = $quizgenerator->create_test_quiz([ ['TF1', 1, 'truefalse'], ['TF2', 1, 'truefalse'], 'Heading 2', ['TF3', 2, 'truefalse'], ]); $structure = structure::create_for_quiz($quizobj); $structure->remove_slot(2); $structure = structure::create_for_quiz($quizobj); $this->assert_quiz_layout([ ['TF1', 1, 'truefalse'], 'Heading 2', ['TF3', 2, 'truefalse'], ], $structure); } public function test_quiz_removing_a_random_question_deletes_the_question(): void { global $DB; $this->resetAfterTest(true); $this->setAdminUser(); $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz'); $quizobj = $quizgenerator->create_test_quiz([ ['TF1', 1, 'truefalse'], ]); $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); $cat = $questiongenerator->create_question_category(); $this->add_random_questions($quizobj->get_quizid(), 1, $cat->id, 1); $structure = structure::create_for_quiz($quizobj); $sql = 'SELECT qsr.* FROM {question_set_references} qsr JOIN {quiz_slots} qs ON qs.id = qsr.itemid WHERE qs.quizid = ? AND qsr.component = ? AND qsr.questionarea = ?'; $randomq = $DB->get_record_sql($sql, [$quizobj->get_quizid(), 'mod_quiz', 'slot']); $structure->remove_slot(2); $structure = structure::create_for_quiz($quizobj); $this->assert_quiz_layout([ ['TF1', 1, 'truefalse'], ], $structure); $this->assertFalse($DB->record_exists('question_set_references', ['id' => $randomq->id, 'component' => 'mod_quiz', 'questionarea' => 'slot'])); } /** * Unit test to make sue it is not possible to remove all slots in a section at once. */ public function test_cannot_remove_all_slots_in_a_section(): void { $this->resetAfterTest(); $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz'); $quizobj = $quizgenerator->create_test_quiz([ ['TF1', 1, 'truefalse'], ['TF2', 1, 'truefalse'], 'Heading 2', ['TF3', 2, 'truefalse'], ]); $structure = structure::create_for_quiz($quizobj); $structure->remove_slot(1); $this->expectException(\coding_exception::class); $structure->remove_slot(2); } public function test_cannot_remove_last_slot_in_a_section(): void { $this->resetAfterTest(); $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz'); $quizobj = $quizgenerator->create_test_quiz([ ['TF1', 1, 'truefalse'], ['TF2', 1, 'truefalse'], 'Heading 2', ['TF3', 2, 'truefalse'], ]); $structure = structure::create_for_quiz($quizobj); $this->expectException(\coding_exception::class); $structure->remove_slot(3); } public function test_can_remove_last_question_in_a_quiz(): void { $this->resetAfterTest(); $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz'); $quizobj = $quizgenerator->create_test_quiz([ 'Heading 1', ['TF1', 1, 'truefalse'], ]); $structure = structure::create_for_quiz($quizobj); $structure->remove_slot(1); $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); $cat = $questiongenerator->create_question_category(); $q = $questiongenerator->create_question('truefalse', null, ['name' => 'TF2', 'category' => $cat->id]); quiz_add_quiz_question($q->id, $quizobj->get_quiz(), 0); $structure = structure::create_for_quiz($quizobj); $this->assert_quiz_layout([ 'Heading 1', ['TF2', 1, 'truefalse'], ], $structure); } public function test_add_question_updates_headings(): void { $this->resetAfterTest(); $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz'); $quizobj = $quizgenerator->create_test_quiz([ ['TF1', 1, 'truefalse'], 'Heading 2', ['TF2', 2, 'truefalse'], ]); $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); $cat = $questiongenerator->create_question_category(); $q = $questiongenerator->create_question('truefalse', null, ['name' => 'TF3', 'category' => $cat->id]); quiz_add_quiz_question($q->id, $quizobj->get_quiz(), 1); $structure = structure::create_for_quiz($quizobj); $this->assert_quiz_layout([ ['TF1', 1, 'truefalse'], ['TF3', 1, 'truefalse'], 'Heading 2', ['TF2', 2, 'truefalse'], ], $structure); } public function test_add_question_updates_headings_even_with_one_question_sections(): void { $this->resetAfterTest(); $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz'); $quizobj = $quizgenerator->create_test_quiz([ 'Heading 1', ['TF1', 1, 'truefalse'], 'Heading 2', ['TF2', 2, 'truefalse'], 'Heading 3', ['TF3', 3, 'truefalse'], ]); $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); $cat = $questiongenerator->create_question_category(); $q = $questiongenerator->create_question('truefalse', null, ['name' => 'TF4', 'category' => $cat->id]); quiz_add_quiz_question($q->id, $quizobj->get_quiz(), 1); $structure = structure::create_for_quiz($quizobj); $this->assert_quiz_layout([ 'Heading 1', ['TF1', 1, 'truefalse'], ['TF4', 1, 'truefalse'], 'Heading 2', ['TF2', 2, 'truefalse'], 'Heading 3', ['TF3', 3, 'truefalse'], ], $structure); } public function test_add_question_at_end_does_not_update_headings(): void { $this->resetAfterTest(); $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz'); $quizobj = $quizgenerator->create_test_quiz([ ['TF1', 1, 'truefalse'], 'Heading 2', ['TF2', 2, 'truefalse'], ]); $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); $cat = $questiongenerator->create_question_category(); $q = $questiongenerator->create_question('truefalse', null, ['name' => 'TF3', 'category' => $cat->id]); quiz_add_quiz_question($q->id, $quizobj->get_quiz(), 0); $structure = structure::create_for_quiz($quizobj); $this->assert_quiz_layout([ ['TF1', 1, 'truefalse'], 'Heading 2', ['TF2', 2, 'truefalse'], ['TF3', 2, 'truefalse'], ], $structure); } public function test_remove_page_break(): void { $this->resetAfterTest(); $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz'); $quizobj = $quizgenerator->create_test_quiz([ ['TF1', 1, 'truefalse'], ['TF2', 2, 'truefalse'], ]); $structure = structure::create_for_quiz($quizobj); $slotid = $structure->get_question_in_slot(2)->slotid; $slots = $structure->update_page_break($slotid, repaginate::LINK); $structure = structure::create_for_quiz($quizobj); $this->assert_quiz_layout([ ['TF1', 1, 'truefalse'], ['TF2', 1, 'truefalse'], ], $structure); } public function test_add_page_break(): void { $this->resetAfterTest(); $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz'); $quizobj = $quizgenerator->create_test_quiz([ ['TF1', 1, 'truefalse'], ['TF2', 1, 'truefalse'], ]); $structure = structure::create_for_quiz($quizobj); $slotid = $structure->get_question_in_slot(2)->slotid; $slots = $structure->update_page_break($slotid, repaginate::UNLINK); $structure = structure::create_for_quiz($quizobj); $this->assert_quiz_layout([ ['TF1', 1, 'truefalse'], ['TF2', 2, 'truefalse'], ], $structure); } public function test_update_question_dependency(): void { $this->resetAfterTest(); $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz'); $quizobj = $quizgenerator->create_test_quiz([ ['TF1', 1, 'truefalse'], ['TF2', 1, 'truefalse'], ]); $structure = structure::create_for_quiz($quizobj); // Test adding a dependency. $slotid = $structure->get_slot_id_for_slot(2); $structure->update_question_dependency($slotid, true); // Having done an update, we need to reload $structure. $structure = structure::create_for_quiz($quizobj); $this->assertEquals(1, $structure->is_question_dependent_on_previous_slot(2)); // Test removing a dependency. $structure->update_question_dependency($slotid, false); // Having done an update, we need to reload $structure. $structure = structure::create_for_quiz($quizobj); $this->assertEquals(0, $structure->is_question_dependent_on_previous_slot(2)); } public function test_update_slot_version(): void { $this->resetAfterTest(); $course = $this->getDataGenerator()->create_course(); $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz'); $quiz = $quizgenerator->create_instance(['course' => $course->id, 'questionsperpage' => 0, 'grade' => 100.0, 'sumgrades' => 2]); get_coursemodule_from_instance('quiz', $quiz->id, $course->id); $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); $cat = $questiongenerator->create_question_category(); $numq = $questiongenerator->create_question('numerical', null, ['category' => $cat->id]); $questiongenerator->update_question($numq, null, ['name' => 'Second version of numq']); quiz_add_quiz_question($numq->id, $quiz); $quizobj = quiz_settings::create($quiz->id); $quizobj->preload_questions(); [$question] = array_values($quizobj->get_questions(null, false)); $structure = $quizobj->get_structure(); // Updating to a version which exists, should succeed. $this->assertTrue($structure->update_slot_version($question->slotid, 2)); // Updating to the same version as the current version should return false. $this->assertFalse($structure->update_slot_version($question->slotid, 2)); // Updating to a version which does not exists, should throw exception. $this->expectException(coding_exception::class); $this->expectExceptionMessage('Version: 3 does not exist for question bank entry: ' . $question->questionbankentryid); $structure->update_slot_version($question->slotid, 3); } public function test_update_slot_grade_item(): void { $this->resetAfterTest(); $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz'); $quizobj = $quizgenerator->create_test_quiz([ ['TF1', 1, 'truefalse'], ['TF2', 1, 'truefalse'], ]); $gradeitem = $quizgenerator->create_grade_item( ['quizid' => $quizobj->get_quizid(), 'name' => 'Awesomeness!']); $structure = structure::create_for_quiz($quizobj); // Test setting the grade item for a slot. $slot = $structure->get_slot_by_number(1); $this->assertTrue($structure->update_slot_grade_item($slot, $gradeitem->id)); // Having done an update, we need to reload $structure. $structure = structure::create_for_quiz($quizobj); $slot = $structure->get_slot_by_number(1); $this->assertEquals($gradeitem->id, $slot->quizgradeitemid); // Test returns false if no change. $this->assertFalse($structure->update_slot_grade_item($slot, $gradeitem->id)); // Test unsetting grade item. $this->assertTrue($structure->update_slot_grade_item($slot, 0)); // Having done an update, we need to reload $structure. $structure = structure::create_for_quiz($quizobj); $slot = $structure->get_slot_by_number(1); $this->assertEquals(null, $slot->quizgradeitemid); // Test returns false if no change. $this->assertFalse($structure->update_slot_grade_item($slot, null)); } public function test_cannot_set_nonnull_slot_grade_item_for_description(): void { $this->resetAfterTest(); $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz'); $quizobj = $quizgenerator->create_test_quiz([ ['Info', 1, 'description'], ]); $gradeitem = $quizgenerator->create_grade_item( ['quizid' => $quizobj->get_quizid(), 'name' => 'Awesomeness!']); $structure = structure::create_for_quiz($quizobj); $this->expectException(coding_exception::class); $structure->update_slot_grade_item($structure->get_slot_by_number(1), $gradeitem->id); } /** * Test for can_add_random_questions. */ public function test_can_add_random_questions(): void { $this->resetAfterTest(); $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz'); $quiz = $quizgenerator->create_test_quiz([]); $course = $quiz->get_course(); $generator = $this->getDataGenerator(); $teacher = $generator->create_and_enrol($course, 'editingteacher'); $nonteacher = $generator->create_and_enrol($course, 'student'); $this->setUser($teacher); $structure = structure::create_for_quiz($quiz); $this->assertTrue($structure->can_add_random_questions()); $this->setUser($nonteacher); $structure = structure::create_for_quiz($quiz); $this->assertFalse($structure->can_add_random_questions()); } /** * Test to get the version information for a question to show in the version selection dropdown. * * @covers ::get_question_version_info */ public function test_get_version_choices_for_slot(): void { $this->resetAfterTest(); $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz'); $quizobj = $quizgenerator->create_test_quiz([]); // Create a question with two versions. $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); $cat = $questiongenerator->create_question_category(['contextid' => $quizobj->get_context()->id]); $q = $questiongenerator->create_question('essay', null, ['category' => $cat->id, 'name' => 'This is the first version']); $questiongenerator->update_question($q, null, ['name' => 'This is the second version']); $questiongenerator->update_question($q, null, ['name' => 'This is the third version']); quiz_add_quiz_question($q->id, $quizobj->get_quiz()); // Create the quiz object. $structure = structure::create_for_quiz($quizobj); $versiondata = $structure->get_version_choices_for_slot(1); $this->assertEquals(4, count($versiondata)); $this->assertEquals('Always latest', $versiondata[0]->versionvalue); $this->assertEquals('v3 (latest)', $versiondata[1]->versionvalue); $this->assertEquals('v2', $versiondata[2]->versionvalue); $this->assertEquals('v1', $versiondata[3]->versionvalue); $this->assertTrue($versiondata[0]->selected); $this->assertFalse($versiondata[1]->selected); $this->assertFalse($versiondata[2]->selected); $this->assertFalse($versiondata[3]->selected); } /** * Test the current user have '...use' capability over the question(s) in a given slot. * * @covers ::has_use_capability */ public function test_has_use_capability(): void { $this->resetAfterTest(); // Create a quiz with question. $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz'); $quizobj = $quizgenerator->create_test_quiz([]); $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); $cat = $questiongenerator->create_question_category(['contextid' => $quizobj->get_context()->id]); $q = $questiongenerator->create_question('essay', null, ['category' => $cat->id, 'name' => 'This is essay question']); quiz_add_quiz_question($q->id, $quizobj->get_quiz()); // Create the quiz object. $structure = structure::create_for_quiz($quizobj); $slots = $structure->get_slots(); // Get slot. $slotid = array_pop($slots)->slot; $course = $quizobj->get_course(); $generator = $this->getDataGenerator(); $teacher = $generator->create_and_enrol($course, 'editingteacher'); $student = $generator->create_and_enrol($course); $this->setUser($teacher); $this->assertTrue($structure->has_use_capability($slotid)); $this->setUser($student); $this->assertFalse($structure->has_use_capability($slotid)); } } PK9B\,7+1+1deprecatedlib.phpnu[. /** * List of deprecated mod_quiz functions. * * @package mod_quiz * @copyright 2021 Shamim Rezaie * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ use mod_quiz\access_manager; use mod_quiz\quiz_settings; use mod_quiz\task\update_overdue_attempts; /** * Verify that the question exists, and the user has permission to use it. * * @deprecated in 4.1 use mod_quiz\structure::has_use_capability(...) instead. * * @param stdClass $quiz the quiz settings. * @param int $slot which question in the quiz to test. * @return bool whether the user can use this question. */ function quiz_has_question_use($quiz, $slot) { global $DB; debugging('Deprecated. Please use mod_quiz\structure::has_use_capability instead.'); $sql = 'SELECT q.* FROM {quiz_slots} slot JOIN {question_references} qre ON qre.itemid = slot.id JOIN {question_bank_entries} qbe ON qbe.id = qre.questionbankentryid JOIN {question_versions} qve ON qve.questionbankentryid = qbe.id JOIN {question} q ON q.id = qve.questionid WHERE slot.quizid = ? AND slot.slot = ? AND qre.component = ? AND qre.questionarea = ?'; $question = $DB->get_record_sql($sql, [$quiz->id, $slot, 'mod_quiz', 'slot']); if (!$question) { return false; } return question_has_capability_on($question, 'use'); } /** * @copyright 2012 the Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @deprecated since Moodle 4.2. Code moved to mod_quiz\task\update_overdue_attempts. * @todo MDL-76612 Final deprecation in Moodle 4.6 */ class mod_quiz_overdue_attempt_updater { /** * @deprecated since Moodle 4.2. Code moved to mod_quiz\task\update_overdue_attempts. that was. */ public function update_overdue_attempts($timenow, $processto) { debugging('mod_quiz_overdue_attempt_updater has been deprecated. The code wsa moved to ' . 'mod_quiz\task\update_overdue_attempts.'); return (new update_overdue_attempts())->update_all_overdue_attempts((int) $timenow, (int) $processto); } /** * @deprecated since Moodle 4.2. Code moved to mod_quiz\task\update_overdue_attempts. */ public function get_list_of_overdue_attempts($processto) { debugging('mod_quiz_overdue_attempt_updater has been deprecated. The code wsa moved to ' . 'mod_quiz\task\update_overdue_attempts.'); return (new update_overdue_attempts())->get_list_of_overdue_attempts((int) $processto); } } /** * Class for quiz exceptions. Just saves a couple of arguments on the * constructor for a moodle_exception. * * @copyright 2008 Tim Hunt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @since Moodle 2.0 * @deprecated since Moodle 4.2. Please just use moodle_exception. * @todo MDL-76612 Final deprecation in Moodle 4.6 */ class moodle_quiz_exception extends moodle_exception { /** * Constructor. * * @param quiz_settings $quizobj the quiz the error relates to. * @param string $errorcode The name of the string from error.php to print. * @param mixed $a Extra words and phrases that might be required in the error string. * @param string $link The url where the user will be prompted to continue. * If no url is provided the user will be directed to the site index page. * @param string|null $debuginfo optional debugging information. * @deprecated since Moodle 4.2. Please just use moodle_exception. */ public function __construct($quizobj, $errorcode, $a = null, $link = '', $debuginfo = null) { debugging('Class moodle_quiz_exception is deprecated. ' . 'Please use a standard moodle_exception instead.', DEBUG_DEVELOPER); if (!$link) { $link = $quizobj->view_url(); } parent::__construct($errorcode, 'quiz', $link, $a, $debuginfo); } } /** * Update the sumgrades field of the quiz. This needs to be called whenever * the grading structure of the quiz is changed. For example if a question is * added or removed, or a question weight is changed. * * You should call {@see quiz_delete_previews()} before you call this function. * * @param stdClass $quiz a quiz. * @deprecated since Moodle 4.2. Please use grade_calculator::recompute_quiz_sumgrades. * @todo MDL-76612 Final deprecation in Moodle 4.6 */ function quiz_update_sumgrades($quiz) { debugging('quiz_update_sumgrades is deprecated. ' . 'Please use a standard grade_calculator::recompute_quiz_sumgrades instead.', DEBUG_DEVELOPER); quiz_settings::create($quiz->id)->get_grade_calculator()->recompute_quiz_sumgrades(); } /** * Update the sumgrades field of the attempts at a quiz. * * @param stdClass $quiz a quiz. * @deprecated since Moodle 4.2. Please use grade_calculator::recompute_all_attempt_sumgrades. * @todo MDL-76612 Final deprecation in Moodle 4.6 */ function quiz_update_all_attempt_sumgrades($quiz) { debugging('quiz_update_all_attempt_sumgrades is deprecated. ' . 'Please use a standard grade_calculator::recompute_all_attempt_sumgrades instead.', DEBUG_DEVELOPER); quiz_settings::create($quiz->id)->get_grade_calculator()->recompute_all_attempt_sumgrades(); } /** * Update the final grade at this quiz for all students. * * This function is equivalent to calling quiz_save_best_grade for all * users, but much more efficient. * * @param stdClass $quiz the quiz settings. * @deprecated since Moodle 4.2. Please use grade_calculator::recompute_all_final_grades. * @todo MDL-76612 Final deprecation in Moodle 4.6 */ function quiz_update_all_final_grades($quiz) { debugging('quiz_update_all_final_grades is deprecated. ' . 'Please use a standard grade_calculator::recompute_all_final_grades instead.', DEBUG_DEVELOPER); quiz_settings::create($quiz->id)->get_grade_calculator()->recompute_all_final_grades(); } /** * The quiz grade is the maximum that student's results are marked out of. When it * changes, the corresponding data in quiz_grades and quiz_feedback needs to be * rescaled. After calling this function, you probably need to call * quiz_update_all_attempt_sumgrades, grade_calculator::recompute_all_final_grades(); * quiz_update_grades. (At least, that is what this comment has said for years, but * it seems to call recompute_all_final_grades itself.) * * @param float $newgrade the new maximum grade for the quiz. * @param stdClass $quiz the quiz we are updating. Passed by reference so its * grade field can be updated too. * @return bool indicating success or failure. * @deprecated since Moodle 4.2. Please use grade_calculator::update_quiz_maximum_grade. * @todo MDL-76612 Final deprecation in Moodle 4.6 */ function quiz_set_grade($newgrade, $quiz) { debugging('quiz_set_grade is deprecated. ' . 'Please use a standard grade_calculator::update_quiz_maximum_grade instead.', DEBUG_DEVELOPER); quiz_settings::create($quiz->id)->get_grade_calculator()->update_quiz_maximum_grade($newgrade); return true; } /** * Save the overall grade for a user at a quiz in the quiz_grades table * * @param stdClass $quiz The quiz for which the best grade is to be calculated and then saved. * @param int $userid The userid to calculate the grade for. Defaults to the current user. * @param array $attempts The attempts of this user. Useful if you are * looping through many users. Attempts can be fetched in one master query to * avoid repeated querying. * @return bool Indicates success or failure. * @deprecated since Moodle 4.2. Please use grade_calculator::update_quiz_maximum_grade. * @todo MDL-76612 Final deprecation in Moodle 4.6 */ function quiz_save_best_grade($quiz, $userid = null, $attempts = []) { debugging('quiz_save_best_grade is deprecated. ' . 'Please use a standard grade_calculator::recompute_final_grade instead.', DEBUG_DEVELOPER); quiz_settings::create($quiz->id)->get_grade_calculator()->recompute_final_grade($userid, $attempts); return true; } /** * Calculate the overall grade for a quiz given a number of attempts by a particular user. * * @param stdClass $quiz the quiz settings object. * @param array $attempts an array of all the user's attempts at this quiz in order. * @return float the overall grade * @deprecated since Moodle 4.2. No direct replacement. * @todo MDL-76612 Final deprecation in Moodle 4.6 */ function quiz_calculate_best_grade($quiz, $attempts) { debugging('quiz_calculate_best_grade is deprecated with no direct replacement. It was only used ' . 'in one place in the quiz code so this logic is now private to grade_calculator.', DEBUG_DEVELOPER); switch ($quiz->grademethod) { case QUIZ_ATTEMPTFIRST: $firstattempt = reset($attempts); return $firstattempt->sumgrades; case QUIZ_ATTEMPTLAST: $lastattempt = end($attempts); return $lastattempt->sumgrades; case QUIZ_GRADEAVERAGE: $sum = 0; $count = 0; foreach ($attempts as $attempt) { if (!is_null($attempt->sumgrades)) { $sum += $attempt->sumgrades; $count++; } } if ($count == 0) { return null; } return $sum / $count; case QUIZ_GRADEHIGHEST: default: $max = null; foreach ($attempts as $attempt) { if ($attempt->sumgrades > $max) { $max = $attempt->sumgrades; } } return $max; } } /** * Return the attempt with the best grade for a quiz * * Which attempt is the best depends on $quiz->grademethod. If the grade * method is GRADEAVERAGE then this function simply returns the last attempt. * @return stdClass The attempt with the best grade * @param stdClass $quiz The quiz for which the best grade is to be calculated * @param array $attempts An array of all the attempts of the user at the quiz * @deprecated since Moodle 4.2. No direct replacement. * @todo MDL-76612 Final deprecation in Moodle 4.6 */ function quiz_calculate_best_attempt($quiz, $attempts) { debugging('quiz_calculate_best_attempt is deprecated with no direct replacement. ' . 'It was not used anywhere!', DEBUG_DEVELOPER); switch ($quiz->grademethod) { case QUIZ_ATTEMPTFIRST: foreach ($attempts as $attempt) { return $attempt; } break; case QUIZ_GRADEAVERAGE: // We need to do something with it. case QUIZ_ATTEMPTLAST: foreach ($attempts as $attempt) { $final = $attempt; } return $final; default: case QUIZ_GRADEHIGHEST: $max = -1; foreach ($attempts as $attempt) { if ($attempt->sumgrades > $max) { $max = $attempt->sumgrades; $maxattempt = $attempt; } } return $maxattempt; } } /** * @deprecated since Moodle 4.4 MDL-80300 */ #[\core\attribute\deprecated('override_manager::delete_override_by_id', since: '4.4', mdl: '80300', final: true)] function quiz_delete_override($quiz, $overrideid, $log = true) { \core\deprecation::emit_deprecation(__FUNCTION__); return true; } /** * @deprecated since Moodle 4.4 MDL-80300 */ #[\core\attribute\deprecated('override_manager::delete_all_overrides', since: '4.4', mdl: '80300', final: true)] function quiz_delete_all_overrides($quiz, $log = true) { \core\deprecation::emit_deprecation(__FUNCTION__); } PK9B\o|44report/attemptsreport.phpnu[. /** * File only retained to prevent fatal errors in code that tries to require/include this. * * @todo MDL-76612 delete this file as part of Moodle 4.6 development. * @deprecated This file is no longer required in Moodle 4.2+. */ defined('MOODLE_INTERNAL') || die(); debugging('This file is no longer required in Moodle 4.2+. Please do not include/require it.', DEBUG_DEVELOPER); PK9B\o|44report/default.phpnu[. /** * File only retained to prevent fatal errors in code that tries to require/include this. * * @todo MDL-76612 delete this file as part of Moodle 4.6 development. * @deprecated This file is no longer required in Moodle 4.2+. */ defined('MOODLE_INTERNAL') || die(); debugging('This file is no longer required in Moodle 4.2+. Please do not include/require it.', DEBUG_DEVELOPER); PK9B\ >>report/reportlib.phpnu[. /** * Helper functions for the quiz reports. * * @package mod_quiz * @copyright 2008 Jamie Pratt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); require_once($CFG->dirroot . '/mod/quiz/lib.php'); require_once($CFG->libdir . '/filelib.php'); use mod_quiz\question\display_options; /** * Takes an array of objects and constructs a multidimensional array keyed by * the keys it finds on the object. * @param array $datum an array of objects with properties on the object * including the keys passed as the next param. * @param array $keys Array of strings with the names of the properties on the * objects in datum that you want to index the multidimensional array by. * @param bool $keysunique If there is not only one object for each * combination of keys you are using you should set $keysunique to true. * Otherwise all the object will be added to a zero based array. So the array * returned will have count($keys) + 1 indexs. * @return array multidimensional array properly indexed. */ function quiz_report_index_by_keys($datum, $keys, $keysunique = true) { if (!$datum) { return []; } $key = array_shift($keys); $datumkeyed = []; foreach ($datum as $data) { if ($keys || !$keysunique) { $datumkeyed[$data->{$key}][]= $data; } else { $datumkeyed[$data->{$key}]= $data; } } if ($keys) { foreach ($datumkeyed as $datakey => $datakeyed) { $datumkeyed[$datakey] = quiz_report_index_by_keys($datakeyed, $keys, $keysunique); } } return $datumkeyed; } function quiz_report_unindex($datum) { if (!$datum) { return $datum; } $datumunkeyed = []; foreach ($datum as $value) { if (is_array($value)) { $datumunkeyed = array_merge($datumunkeyed, quiz_report_unindex($value)); } else { $datumunkeyed[] = $value; } } return $datumunkeyed; } /** * Are there any questions in this quiz? * @param int $quizid the quiz id. */ function quiz_has_questions($quizid) { global $DB; return $DB->record_exists('quiz_slots', ['quizid' => $quizid]); } /** * Get the slots of real questions (not descriptions) in this quiz, in order. * @param stdClass $quiz the quiz. * @return array of slot => objects with fields * ->slot, ->id, ->qtype, ->length, ->number, ->maxmark, ->category (for random questions). */ function quiz_report_get_significant_questions($quiz) { $quizobj = mod_quiz\quiz_settings::create($quiz->id); $structure = \mod_quiz\structure::create_for_quiz($quizobj); $slots = $structure->get_slots(); $qsbyslot = []; $number = 1; foreach ($slots as $slot) { // Ignore 'questions' of zero length. if ($slot->length == 0) { continue; } $slotreport = new \stdClass(); $slotreport->slot = $slot->slot; $slotreport->id = $slot->questionid; $slotreport->qtype = $slot->qtype; $slotreport->length = $slot->length; $slotreport->number = $number; $slotreport->displaynumber = $slot->displaynumber ?? $number; $number += $slot->length; $slotreport->maxmark = $slot->maxmark; $slotreport->category = $slot->category; $qsbyslot[$slotreport->slot] = $slotreport; } return $qsbyslot; } /** * @param stdClass $quiz the quiz settings. * @return bool whether, for this quiz, it is possible to filter attempts to show * only those that gave the final grade. */ function quiz_report_can_filter_only_graded($quiz) { return $quiz->attempts != 1 && $quiz->grademethod != QUIZ_GRADEAVERAGE; } /** * This is a wrapper for {@link quiz_report_grade_method_sql} that takes the whole quiz object instead of just the grading method * as a param. See definition for {@link quiz_report_grade_method_sql} below. * * @param stdClass $quiz * @param string $quizattemptsalias sql alias for 'quiz_attempts' table * @return string sql to test if this is an attempt that will contribute towards the grade of the user */ function quiz_report_qm_filter_select($quiz, $quizattemptsalias = 'quiza') { if ($quiz->attempts == 1) { // This quiz only allows one attempt. return ''; } return quiz_report_grade_method_sql($quiz->grademethod, $quizattemptsalias); } /** * Given a quiz grading method return sql to test if this is an * attempt that will be contribute towards the grade of the user. Or return an * empty string if the grading method is QUIZ_GRADEAVERAGE and thus all attempts * contribute to final grade. * * @param string $grademethod quiz grading method. * @param string $quizattemptsalias sql alias for 'quiz_attempts' table * @return string sql to test if this is an attempt that will contribute towards the graded of the user */ function quiz_report_grade_method_sql($grademethod, $quizattemptsalias = 'quiza') { switch ($grademethod) { case QUIZ_GRADEHIGHEST : return "($quizattemptsalias.state = 'finished' AND NOT EXISTS ( SELECT 1 FROM {quiz_attempts} qa2 WHERE qa2.quiz = $quizattemptsalias.quiz AND qa2.userid = $quizattemptsalias.userid AND qa2.state = 'finished' AND ( COALESCE(qa2.sumgrades, 0) > COALESCE($quizattemptsalias.sumgrades, 0) OR (COALESCE(qa2.sumgrades, 0) = COALESCE($quizattemptsalias.sumgrades, 0) AND qa2.attempt < $quizattemptsalias.attempt) )))"; case QUIZ_GRADEAVERAGE : return ''; case QUIZ_ATTEMPTFIRST : return "( $quizattemptsalias.state IN ('finished', 'submitted') AND NOT EXISTS ( SELECT 1 FROM {quiz_attempts} qa2 WHERE qa2.quiz = $quizattemptsalias.quiz AND qa2.userid = $quizattemptsalias.userid AND qa2.state IN ('finished', 'submitted') AND qa2.attempt < $quizattemptsalias.attempt ) )"; case QUIZ_ATTEMPTLAST : return "( $quizattemptsalias.state IN ('finished', 'submitted') AND NOT EXISTS ( SELECT 1 FROM {quiz_attempts} qa2 WHERE qa2.quiz = $quizattemptsalias.quiz AND qa2.userid = $quizattemptsalias.userid AND qa2.state IN ('finished', 'submitted') AND qa2.attempt > $quizattemptsalias.attempt ) )"; } } /** * Get the number of students whose score was in a particular band for this quiz. * @param number $bandwidth the width of each band. * @param int $bands the number of bands * @param int $quizid the quiz id. * @param \core\dml\sql_join $usersjoins (joins, wheres, params) to get enrolled users * @return array band number => number of users with scores in that band. */ function quiz_report_grade_bands($bandwidth, $bands, $quizid, ?\core\dml\sql_join $usersjoins = null) { global $DB; if (!is_int($bands)) { debugging('$bands passed to quiz_report_grade_bands must be an integer. (' . gettype($bands) . ' passed.)', DEBUG_DEVELOPER); $bands = (int) $bands; } if ($usersjoins && !empty($usersjoins->joins)) { $userjoin = "JOIN {user} u ON u.id = qg.userid {$usersjoins->joins}"; $usertest = $usersjoins->wheres; $params = $usersjoins->params; } else { $userjoin = ''; $usertest = '1=1'; $params = []; } $sql = " SELECT band, COUNT(1) FROM ( SELECT FLOOR(qg.grade / :bandwidth) AS band FROM {quiz_grades} qg $userjoin WHERE $usertest AND qg.quiz = :quizid ) subquery GROUP BY band ORDER BY band"; $params['quizid'] = $quizid; $params['bandwidth'] = $bandwidth; $data = $DB->get_records_sql_menu($sql, $params); // We need to create array elements with values 0 at indexes where there is no element. $data = $data + array_fill(0, $bands + 1, 0); ksort($data); // Place the maximum (perfect grade) into the last band i.e. make last // band for example 9 <= g <=10 (where 10 is the perfect grade) rather than // just 9 <= g <10. $data[$bands - 1] += $data[$bands]; unset($data[$bands]); // See MDL-60632. When a quiz participant achieves an overall negative grade the chart fails to render. foreach ($data as $databand => $datanum) { if ($databand < 0) { $data["0"] += $datanum; // Add to band 0. unset($data[$databand]); // Remove entry below 0. } } return $data; } function quiz_report_highlighting_grading_method($quiz, $qmsubselect, $qmfilter) { if ($quiz->attempts == 1) { return '

                ' . get_string('onlyoneattemptallowed', 'quiz_overview') . '

                '; } else if (!$qmsubselect) { return '

                ' . get_string('allattemptscontributetograde', 'quiz_overview') . '

                '; } else if ($qmfilter) { return '

                ' . get_string('showinggraded', 'quiz_overview') . '

                '; } else { return '

                ' . get_string('showinggradedandungraded', 'quiz_overview', '' . quiz_get_grading_option_name($quiz->grademethod) . '') . '

                '; } } /** * Get the feedback text for a grade on this quiz. The feedback is * processed ready for display. * * @param float $grade a grade on this quiz. * @param int $quizid the id of the quiz object. * @return string the comment that corresponds to this grade (empty string if there is not one. */ function quiz_report_feedback_for_grade($grade, $quizid, $context) { global $DB; static $feedbackcache = []; if (!isset($feedbackcache[$quizid])) { $feedbackcache[$quizid] = $DB->get_records('quiz_feedback', ['quizid' => $quizid]); } // With CBM etc, it is possible to get -ve grades, which would then not match // any feedback. Therefore, we replace -ve grades with 0. $grade = max($grade, 0); $feedbacks = $feedbackcache[$quizid]; $feedbackid = 0; $feedbacktext = ''; $feedbacktextformat = FORMAT_MOODLE; foreach ($feedbacks as $feedback) { if ($feedback->mingrade <= $grade && $grade < $feedback->maxgrade) { $feedbackid = $feedback->id; $feedbacktext = $feedback->feedbacktext; $feedbacktextformat = $feedback->feedbacktextformat; break; } } // Clean the text, ready for display. $formatoptions = new stdClass(); $formatoptions->noclean = true; $feedbacktext = file_rewrite_pluginfile_urls($feedbacktext, 'pluginfile.php', $context->id, 'mod_quiz', 'feedback', $feedbackid); $feedbacktext = format_text($feedbacktext, $feedbacktextformat, $formatoptions); return $feedbacktext; } /** * Format a number as a percentage out of $quiz->sumgrades * @param number $rawgrade the mark to format. * @param stdClass $quiz the quiz settings * @param bool $round whether to round the results ot $quiz->decimalpoints. */ function quiz_report_scale_summarks_as_percentage($rawmark, $quiz, $round = true) { if ($quiz->sumgrades == 0) { return ''; } if (!is_numeric($rawmark)) { return $rawmark; } $mark = $rawmark * 100 / $quiz->sumgrades; if ($round) { $mark = quiz_format_grade($quiz, $mark); } return get_string('percents', 'moodle', $mark); } /** * Returns an array of reports to which the current user has access to. * @return array reports are ordered as they should be for display in tabs. */ function quiz_report_list($context) { global $DB; static $reportlist = null; if (!is_null($reportlist)) { return $reportlist; } $reports = $DB->get_records('quiz_reports', null, 'displayorder DESC', 'name, capability'); $reportdirs = core_component::get_plugin_list('quiz'); // Order the reports tab in descending order of displayorder. $reportcaps = []; foreach ($reports as $key => $report) { if (array_key_exists($report->name, $reportdirs)) { $reportcaps[$report->name] = $report->capability; } } // Add any other reports, which are on disc but not in the DB, on the end. foreach ($reportdirs as $reportname => $notused) { if (!isset($reportcaps[$reportname])) { $reportcaps[$reportname] = null; } } $reportlist = []; foreach ($reportcaps as $name => $capability) { if (empty($capability)) { $capability = 'mod/quiz:viewreports'; } if (has_capability($capability, $context)) { $reportlist[] = $name; } } return $reportlist; } /** * Create a filename for use when downloading data from a quiz report. It is * expected that this will be passed to flexible_table::is_downloading, which * cleans the filename of bad characters and adds the file extension. * @param string $report the type of report. * @param string $courseshortname the course shortname. * @param string $quizname the quiz name. * @return string the filename. */ function quiz_report_download_filename($report, $courseshortname, $quizname) { return $courseshortname . '-' . format_string($quizname, true) . '-' . $report; } /** * Get the default report for the current user. * @param stdClass $context the quiz context. */ function quiz_report_default_report($context) { $reports = quiz_report_list($context); return reset($reports); } /** * Generate a message saying that this quiz has no questions, with a button to * go to the edit page, if the user has the right capability. * @param stdClass $quiz the quiz settings. * @param stdClass $cm the course_module object. * @param stdClass $context the quiz context. * @return string HTML to output. */ function quiz_no_questions_message($quiz, $cm, $context) { global $OUTPUT; $output = ''; $output .= $OUTPUT->notification(get_string('noquestions', 'quiz')); if (has_capability('mod/quiz:manage', $context)) { $output .= $OUTPUT->single_button(new moodle_url('/mod/quiz/edit.php', ['cmid' => $cm->id]), get_string('editquiz', 'quiz'), 'get'); } return $output; } /** * Should the grades be displayed in this report. That depends on the quiz * display options, and whether the quiz is graded. * @param stdClass $quiz the quiz settings. * @param context $context the quiz context. * @return bool */ function quiz_report_should_show_grades($quiz, context $context) { if ($quiz->timeclose && time() > $quiz->timeclose) { $when = display_options::AFTER_CLOSE; } else { $when = display_options::LATER_WHILE_OPEN; } $reviewoptions = display_options::make_from_quiz($quiz, $when); return quiz_has_grades($quiz) && ($reviewoptions->marks >= question_display_options::MARK_AND_MAX || has_capability('moodle/grade:viewhidden', $context)); } PK9B\_gIreport/overview/db/install.phpnu[. /** * Post-install script for the quiz grades report. * @package quiz_overview * @copyright 2013 Tim Hunt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); /** * Post-install script */ function xmldb_quiz_overview_install() { global $DB; $record = new stdClass(); $record->name = 'overview'; $record->displayorder = '10000'; $DB->insert_record('quiz_reports', $record); } PK9B\..report/overview/db/upgrade.phpnu[. /** * Quiz overview report upgrade script. * * @package quiz_overview * @copyright 2008 Jamie Pratt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ /** * Quiz overview report upgrade function. * @param number $oldversion */ function xmldb_quiz_overview_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; } PK9B\m<<report/overview/db/install.xmlnu[
                PK9B\)report/overview/lang/en/quiz_overview.phpnu[. /** * Strings for component 'quiz_overview', language 'en', branch 'MOODLE_20_STABLE' * * @package quiz_overview * @copyright 1999 onwards Martin Dougiamas {@link http://moodle.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ $string['allattempts'] = 'Show all attempts'; $string['allattemptscontributetograde'] = 'All attempts contribute to final grade for user.'; $string['allstudents'] = 'Show all {$a}'; $string['attemptsonly'] = 'Show {$a} with attempts only'; $string['attemptsprepage'] = 'Attempts shown per page'; $string['deleteselected'] = 'Delete selected attempts'; $string['done'] = 'Done'; $string['err_failedtodeleteregrades'] = 'Failed to delete calculated attempt grades'; $string['err_failedtorecalculateattemptgrades'] = 'Failed to recalculate attempt grades'; $string['highlightinggraded'] = 'The user attempt that contributes to final grade is highlighted.'; $string['needed'] = 'Needed'; $string['noattemptsonly'] = 'Show / download {$a} with no attempts only'; $string['noattemptstoregrade'] = 'No attempts need regrading'; $string['nogradepermission'] = 'You don\'t have permission to grade this quiz.'; $string['onlyoneattemptallowed'] = 'Only one attempt per user allowed on this quiz.'; $string['optallattempts'] = 'all attempts'; $string['optallstudents'] = 'all {$a} who have or have not attempted the quiz'; $string['optattemptsonly'] = '{$a} who have attempted the quiz'; $string['optnoattemptsonly'] = '{$a} who have not attempted the quiz'; $string['optonlyregradedattempts'] = 'that have been regraded / are marked as needing regrading'; $string['overview'] = 'Grades'; $string['overviewdownload'] = 'Overview download'; $string['overviewfilename'] = 'grades'; $string['overviewreport'] = 'Grades report'; $string['overviewreportgraph'] = 'Overall number of students achieving grade ranges'; $string['overviewreportgraphgroup'] = 'Number of students in group \'{$a}\' achieving grade ranges'; $string['pagesize'] = 'Page size'; $string['pluginname'] = 'Grades'; $string['preferencespage'] = 'Preferences just for this page'; $string['preferencessave'] = 'Show report'; $string['preferencesuser'] = 'Your preferences for this report'; $string['privacy:metadata:preference:quiz_overview_slotmarks'] = 'Whether to show marks for each question slot.'; $string['privacy:preference:slotmarks:yes'] = 'Marks are shown alongside the question slot.'; $string['privacy:preference:slotmarks:no'] = 'Marks are not shown alongside the question slot.'; $string['regrade'] = 'Regrade'; $string['regrade_allattempts'] = 'All attempts'; $string['regrade_allquestions'] = 'All questions'; $string['regrade_attempts'] = 'Regrade attempts...'; $string['regrade_commitregrade'] = 'Commit regrade'; $string['regrade_dryrun'] = 'Dry run'; $string['regrade_help'] = 'Regrading recalculates the automated grading of attempts, using the current version of each question if possible. A dry run allows you to preview the grade changes without affecting attempts.'; $string['regrade_link'] = 'Quiz_grades_report#Re-grading_attempts'; $string['regrade_regradeneedednotificationmessage'] = 'The last dry run of a regrade found that the regrade would change the marks for {$a->questions} questions in {$a->attempts} attempts.'; $string['regrade_regradenow'] = 'Regrade now'; $string['regrade_selectedattempts'] = 'Selected attempts'; $string['regrade_selectedquestions'] = 'Selected questions'; $string['regradecomplete'] = 'Regrade completed'; $string['regradedsuccessfullyxofy'] = 'Finished regrading ({$a->done}/{$a->count})'; $string['regradeheader'] = 'Regrading'; $string['regradingattemptissue'] = 'Slot {$a->slot}: {$a->reason}'; $string['regradingattemptxofy'] = 'Regrading attempt ({$a->done}/{$a->count})'; $string['regradingattemptxofyproblem'] = 'The following questions could not be regraded in attempt {$a->attemptnum} by {$a->name} (id {$a->attemptid})'; $string['regradingattemptxofywithdetails'] = 'Regrading attempt ({$a->done}/{$a->count}) - Attempt {$a->attemptnum} by {$a->name} (id {$a->attemptid})'; $string['show'] = 'Show / download'; $string['showattempts'] = 'Only show / download attempts'; $string['showdetailedmarks'] = 'Marks for each question'; $string['showinggraded'] = 'Showing only the attempt graded for each user.'; $string['showinggradedandungraded'] = 'Showing graded and ungraded attempts for each user. The one attempt for each user that is graded is highlighted. The grading method for this quiz is {$a}.'; $string['studentingroup'] = '\'{$a->coursestudent}\' in group \'{$a->groupname}\''; $string['studentingrouplong'] = '\'{$a->coursestudent}\' in this group'; PK9B\4 $report/overview/overview_options.phpnu[. use mod_quiz\local\reports\attempts_report; use mod_quiz\local\reports\attempts_report_options; /** * Class to store the options for a {@link quiz_overview_report}. * * @copyright 2012 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class quiz_overview_options extends attempts_report_options { /** @var bool whether to show only attempt that need regrading. */ public $onlyregraded = false; /** @var bool whether to show marks for each question (slot). */ public $slotmarks = true; protected function get_url_params() { $params = parent::get_url_params(); $params['onlyregraded'] = $this->onlyregraded; $params['slotmarks'] = $this->slotmarks; return $params; } public function get_initial_form_data() { $toform = parent::get_initial_form_data(); $toform->onlyregraded = $this->onlyregraded; $toform->slotmarks = $this->slotmarks; return $toform; } public function setup_from_form_data($fromform) { parent::setup_from_form_data($fromform); $this->onlyregraded = !empty($fromform->onlyregraded); $this->slotmarks = $fromform->slotmarks; } public function setup_from_params() { parent::setup_from_params(); $this->onlyregraded = optional_param('onlyregraded', $this->onlyregraded, PARAM_BOOL); $this->slotmarks = optional_param('slotmarks', $this->slotmarks, PARAM_BOOL); } public function setup_from_user_preferences() { parent::setup_from_user_preferences(); $this->slotmarks = get_user_preferences('quiz_overview_slotmarks', $this->slotmarks); } public function update_user_preferences() { parent::update_user_preferences(); if (quiz_has_grades($this->quiz)) { set_user_preference('quiz_overview_slotmarks', $this->slotmarks); } } public function resolve_dependencies() { parent::resolve_dependencies(); if ($this->attempts == attempts_report::ENROLLED_WITHOUT) { $this->onlyregraded = false; } if (!$this->usercanseegrades) { $this->slotmarks = false; } // We only want to show the checkbox to delete attempts // if the user has permissions and if the report mode is showing attempts. $this->checkboxcolumn = has_any_capability( ['mod/quiz:regrade', 'mod/quiz:deleteattempts'], context_module::instance($this->cm->id)) && ($this->attempts != attempts_report::ENROLLED_WITHOUT); } } PK9B\careport/overview/version.phpnu[. /** * Quiz overview report version information. * * @package quiz_overview * @copyright 2008 Jamie Pratt * @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 = 'quiz_overview'; PK9B\3<<"report/overview/overview_table.phpnu[. use mod_quiz\local\reports\attempts_report_table; use mod_quiz\quiz_attempt; /** * This is a table subclass for displaying the quiz grades report. * * @copyright 2008 Jamie Pratt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class quiz_overview_table extends attempts_report_table { /** @var array used to store information about which questoins have been regraded. */ protected $regradedqs = []; /** * Constructor * @param stdClass $quiz * @param context $context * @param string $qmsubselect * @param quiz_overview_options $options * @param \core\dml\sql_join $groupstudentsjoins * @param \core\dml\sql_join $studentsjoins * @param array $questions * @param moodle_url $reporturl */ public function __construct($quiz, $context, $qmsubselect, quiz_overview_options $options, \core\dml\sql_join $groupstudentsjoins, \core\dml\sql_join $studentsjoins, $questions, $reporturl) { parent::__construct('mod-quiz-report-overview-report', $quiz , $context, $qmsubselect, $options, $groupstudentsjoins, $studentsjoins, $questions, $reporturl); } public function build_table() { global $DB; if (!$this->rawdata) { return; } $this->strtimeformat = str_replace(',', ' ', get_string('strftimedatetime')); parent::build_table(); // End of adding the data from attempts. Now add averages at bottom. $this->add_separator(); if (!empty($this->groupstudentsjoins->joins)) { $hasgroupstudents = $DB->record_exists_sql(" SELECT 1 FROM {user} u {$this->groupstudentsjoins->joins} WHERE {$this->groupstudentsjoins->wheres} ", $this->groupstudentsjoins->params); if ($hasgroupstudents) { $this->add_average_row(get_string('groupavg', 'grades'), $this->groupstudentsjoins); } } if (!empty($this->studentsjoins->joins)) { $hasstudents = $DB->record_exists_sql(" SELECT 1 FROM {user} u {$this->studentsjoins->joins} WHERE {$this->studentsjoins->wheres} " , $this->studentsjoins->params); if ($hasstudents) { $this->add_average_row(get_string('overallaverage', 'grades'), $this->studentsjoins); } } } /** * Calculate the average overall and question scores for a set of attempts at the quiz. * * @param string $label the title ot use for this row. * @param \core\dml\sql_join $usersjoins to indicate a set of users. * @return array of table cells that make up the average row. */ public function compute_average_row($label, \core\dml\sql_join $usersjoins) { global $DB; list($fields, $from, $where, $params) = $this->base_sql($usersjoins); $record = $DB->get_record_sql(" SELECT AVG(quizaouter.sumgrades) AS grade, COUNT(quizaouter.sumgrades) AS numaveraged FROM {quiz_attempts} quizaouter JOIN ( SELECT DISTINCT quiza.id FROM $from WHERE $where ) relevant_attempt_ids ON quizaouter.id = relevant_attempt_ids.id ", $params); $record->grade = quiz_rescale_grade($record->grade, $this->quiz, false); if ($this->is_downloading()) { $namekey = 'lastname'; } else { $namekey = 'fullname'; } $averagerow = [ $namekey => $label, 'sumgrades' => $this->format_average($record), 'feedbacktext' => strip_tags(quiz_report_feedback_for_grade( $record->grade, $this->quiz->id, $this->context)) ]; if ($this->options->slotmarks) { $dm = new question_engine_data_mapper(); $qubaids = new qubaid_join("{quiz_attempts} quizaouter JOIN ( SELECT DISTINCT quiza.id FROM $from WHERE $where ) relevant_attempt_ids ON quizaouter.id = relevant_attempt_ids.id", 'quizaouter.uniqueid', '1 = 1', $params); $avggradebyq = $dm->load_average_marks($qubaids, array_keys($this->questions)); $averagerow += $this->format_average_grade_for_questions($avggradebyq); } return $averagerow; } /** * Add an average grade row for a set of users. * * @param string $label the title ot use for this row. * @param \core\dml\sql_join $usersjoins (joins, wheres, params) for the users to average over. */ protected function add_average_row($label, \core\dml\sql_join $usersjoins) { $averagerow = $this->compute_average_row($label, $usersjoins); $this->add_data_keyed($averagerow); } /** * Helper userd by {@link add_average_row()}. * @param array $gradeaverages the raw grades. * @return array the (partial) row of data. */ protected function format_average_grade_for_questions($gradeaverages) { $row = []; if (!$gradeaverages) { $gradeaverages = []; } foreach ($this->questions as $question) { if (isset($gradeaverages[$question->slot]) && $question->maxmark > 0) { $record = $gradeaverages[$question->slot]; $record->grade = quiz_rescale_grade( $record->averagefraction * $question->maxmark, $this->quiz, false); } else { $record = new stdClass(); $record->grade = null; $record->numaveraged = 0; } $row['qsgrade' . $question->slot] = $this->format_average($record, true); } return $row; } /** * Format an entry in an average row. * @param stdClass $record with fields grade and numaveraged. * @param bool $question true if this is a question score, false if it is an overall score. * @return string HTML fragment for an average score (with number of things included in the average). */ protected function format_average($record, $question = false) { if (is_null($record->grade)) { $average = '-'; } else if ($question) { $average = quiz_format_question_grade($this->quiz, $record->grade); } else { $average = quiz_format_grade($this->quiz, $record->grade); } if ($this->download) { return $average; } else if (is_null($record->numaveraged) || $record->numaveraged == 0) { return html_writer::tag('span', html_writer::tag('span', $average, ['class' => 'average']), ['class' => 'avgcell']); } else { return html_writer::tag('span', html_writer::tag('span', $average, ['class' => 'average']) . ' ' . html_writer::tag('span', '(' . $record->numaveraged . ')', ['class' => 'count']), ['class' => 'avgcell']); } } protected function submit_buttons() { global $PAGE, $OUTPUT; parent::submit_buttons(); if (has_capability('mod/quiz:regrade', $this->context)) { $questionslots = []; foreach ($this->questions as $slot => $question) { $questionslots[] = [ 'slot' => $slot, 'name' => get_string('questionno', 'question', $question->number), ]; } echo html_writer::empty_tag('input', [ 'type' => 'button', 'id' => 'regradeattempts', 'class' => 'btn btn-secondary me-1', 'name' => 'regradeattempts', 'value' => get_string('regrade_attempts', 'quiz_overview'), 'data-slots' => json_encode($questionslots), // There is currently not a good way to render a help icon in a template, so put the required HTML here. 'data-help-icon' => $OUTPUT->help_icon('regrade', 'quiz_overview'), ]); $PAGE->requires->js_call_amd('quiz_overview/regrade_modal', 'init'); } } public function col_sumgrades($attempt) { if ($attempt->state != quiz_attempt::FINISHED) { return '-'; } $grade = quiz_rescale_grade($attempt->sumgrades, $this->quiz); if ($this->is_downloading()) { return $grade; } if (isset($this->regradedqs[$attempt->usageid])) { $newsumgrade = 0; $oldsumgrade = 0; foreach ($this->questions as $question) { if (isset($this->regradedqs[$attempt->usageid][$question->slot])) { $newsumgrade += $this->regradedqs[$attempt->usageid] [$question->slot]->newfraction * $question->maxmark; $oldsumgrade += $this->regradedqs[$attempt->usageid] [$question->slot]->oldfraction * $question->maxmark; } else { $newsumgrade += $this->lateststeps[$attempt->usageid] [$question->slot]->fraction * $question->maxmark; $oldsumgrade += $this->lateststeps[$attempt->usageid] [$question->slot]->fraction * $question->maxmark; } } $newsumgrade = quiz_rescale_grade($newsumgrade, $this->quiz); $oldsumgrade = quiz_rescale_grade($oldsumgrade, $this->quiz); $grade = html_writer::tag('del', $oldsumgrade) . '/' . html_writer::empty_tag('br') . $newsumgrade; } return html_writer::link(new moodle_url('/mod/quiz/review.php', ['attempt' => $attempt->attempt]), $grade, ['title' => get_string('reviewattempt', 'quiz')]); } /** * @param string $colname the name of the column. * @param stdClass $attempt the row of data - see the SQL in display() in * mod/quiz/report/overview/report.php to see what fields are present, * and what they are called. * @return string the contents of the cell. */ public function other_cols($colname, $attempt) { if (!preg_match('/^qsgrade(\d+)$/', $colname, $matches)) { return parent::other_cols($colname, $attempt); } $slot = $matches[1]; $question = $this->questions[$slot]; if (!isset($this->lateststeps[$attempt->usageid][$slot])) { return '-'; } $stepdata = $this->lateststeps[$attempt->usageid][$slot]; $state = question_state::get($stepdata->state); if ($question->maxmark == 0) { $grade = '-'; } else if (is_null($stepdata->fraction)) { if ($state == question_state::$needsgrading) { $grade = get_string('requiresgrading', 'question'); } else { $grade = '-'; } } else { $grade = quiz_rescale_grade( $stepdata->fraction * $question->maxmark, $this->quiz, 'question'); } if ($this->is_downloading()) { return $grade; } if (isset($this->regradedqs[$attempt->usageid][$slot])) { $gradefromdb = $grade; $newgrade = quiz_rescale_grade( $this->regradedqs[$attempt->usageid][$slot]->newfraction * $question->maxmark, $this->quiz, 'question'); $oldgrade = quiz_rescale_grade( $this->regradedqs[$attempt->usageid][$slot]->oldfraction * $question->maxmark, $this->quiz, 'question'); $grade = html_writer::tag('del', $oldgrade) . '/' . html_writer::empty_tag('br') . $newgrade; } return $this->make_review_link($grade, $attempt, $slot); } public function col_regraded($attempt) { if ($attempt->regraded == '') { return ''; } else if ($attempt->regraded == 0) { return get_string('needed', 'quiz_overview'); } else if ($attempt->regraded == 1) { return get_string('done', 'quiz_overview'); } } protected function update_sql_after_count($fields, $from, $where, $params) { $fields .= ", COALESCE(( SELECT MAX(qqr.regraded) FROM {quiz_overview_regrades} qqr WHERE qqr.questionusageid = quiza.uniqueid ), -1) AS regraded"; if ($this->options->onlyregraded) { $where .= " AND COALESCE(( SELECT MAX(qqr.regraded) FROM {quiz_overview_regrades} qqr WHERE qqr.questionusageid = quiza.uniqueid ), -1) <> -1"; } return [$fields, $from, $where, $params]; } protected function requires_latest_steps_loaded() { return $this->options->slotmarks; } protected function is_latest_step_column($column) { if (preg_match('/^qsgrade([0-9]+)/', $column, $matches)) { return $matches[1]; } return false; } protected function get_required_latest_state_fields($slot, $alias) { return "$alias.fraction * $alias.maxmark AS qsgrade$slot"; } public function query_db($pagesize, $useinitialsbar = true) { parent::query_db($pagesize, $useinitialsbar); if ($this->options->slotmarks && has_capability('mod/quiz:regrade', $this->context)) { $this->regradedqs = $this->get_regraded_questions(); } } /** * Get all the questions in all the attempts being displayed that need regrading. * @return array A two dimensional array $questionusageid => $slot => $regradeinfo. */ protected function get_regraded_questions() { global $DB; $qubaids = $this->get_qubaids_condition(); $regradedqs = $DB->get_records_select('quiz_overview_regrades', 'questionusageid ' . $qubaids->usage_id_in(), $qubaids->usage_id_in_params()); return quiz_report_index_by_keys($regradedqs, ['questionusageid', 'slot']); } } PK9B\(G ,report/overview/classes/privacy/provider.phpnu[. /** * Privacy Subsystem implementation for quiz_overview.. * * @package quiz_overview * @copyright 2018 Andrew Nicols * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace quiz_overview\privacy; use \core_privacy\local\request\writer; use \core_privacy\local\request\transform; use \core_privacy\local\metadata\collection; use \core_privacy\manager; defined('MOODLE_INTERNAL') || die(); /** * Privacy Subsystem implementation for quiz_overview.. * * @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 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('quiz_overview_slotmarks', 'privacy:metadata:preference:quiz_overview_slotmarks'); 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('quiz_overview_slotmarks', null, $userid); if (null !== $preference) { if (empty($preference)) { $description = get_string('privacy:preference:slotmarks:no', 'quiz_overview'); } else { $description = get_string('privacy:preference:slotmarks:yes', 'quiz_overview'); } writer::export_user_preference( 'quiz_overview', 'slotmarks', transform::yesno($preference), $description ); } } } PK9B\JJ5report/overview/templates/regrade_modal_body.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 quiz_overview/regrade_modal_body Example context (json): { "actionurl": "https://example.com/moodle/mod/quiz/report.php?mode=overview?id=123", "hasselectedattempts": false, "questions": [ {"slot": 3, "name": "Question 1"}, {"slot": 5, "name": "Question 2"} ], "hiddeninputs": [ {"name": "cmid", "value": 123} ] } }} {{#hiddeninputs}} {{/hiddeninputs}}
                {{#questions}}
                {{/questions}}
                PK9B\Evttreport/overview/report.phpnu[. /** * This file defines the quiz overview report class. * * @package quiz_overview * @copyright 1999 onwards Martin Dougiamas and others {@link http://moodle.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ use mod_quiz\local\reports\attempts_report; use mod_quiz\question\bank\qbank_helper; use mod_quiz\quiz_attempt; defined('MOODLE_INTERNAL') || die(); require_once($CFG->dirroot . '/mod/quiz/report/overview/overview_options.php'); require_once($CFG->dirroot . '/mod/quiz/report/overview/overview_form.php'); require_once($CFG->dirroot . '/mod/quiz/report/overview/overview_table.php'); /** * Quiz report subclass for the overview (grades) report. * * @copyright 1999 onwards Martin Dougiamas and others {@link http://moodle.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class quiz_overview_report extends attempts_report { public function display($quiz, $cm, $course) { global $DB, $PAGE, $OUTPUT; list($currentgroup, $studentsjoins, $groupstudentsjoins, $allowedjoins) = $this->init( 'overview', 'quiz_overview_settings_form', $quiz, $cm, $course); $options = new quiz_overview_options('overview', $quiz, $cm, $course); if ($fromform = $this->form->get_data()) { $options->process_settings_from_form($fromform); } else { $options->process_settings_from_params(); } $this->form->set_data($options->get_initial_form_data()); // Load the required questions. $questions = quiz_report_get_significant_questions($quiz); // Prepare for downloading, if applicable. $courseshortname = format_string($course->shortname, true, ['context' => context_course::instance($course->id)]); $table = new quiz_overview_table($quiz, $this->context, $this->qmsubselect, $options, $groupstudentsjoins, $studentsjoins, $questions, $options->get_url()); $filename = quiz_report_download_filename(get_string('overviewfilename', 'quiz_overview'), $courseshortname, $quiz->name); $table->is_downloading($options->download, $filename, $courseshortname . ' ' . format_string($quiz->name, true)); if ($table->is_downloading()) { raise_memory_limit(MEMORY_EXTRA); } $this->hasgroupstudents = false; if (!empty($groupstudentsjoins->joins)) { $sql = "SELECT DISTINCT u.id FROM {user} u $groupstudentsjoins->joins WHERE $groupstudentsjoins->wheres"; $this->hasgroupstudents = $DB->record_exists_sql($sql, $groupstudentsjoins->params); } $hasstudents = false; if (!empty($studentsjoins->joins)) { $sql = "SELECT DISTINCT u.id FROM {user} u $studentsjoins->joins WHERE $studentsjoins->wheres"; $hasstudents = $DB->record_exists_sql($sql, $studentsjoins->params); } if ($options->attempts == self::ALL_WITH) { // This option is only available to users who can access all groups in // groups mode, so setting allowed to empty (which means all quiz attempts // are accessible, is not a security problem. $allowedjoins = new \core\dml\sql_join(); } $this->process_actions($quiz, $cm, $currentgroup, $groupstudentsjoins, $allowedjoins, $options->get_url()); $hasquestions = quiz_has_questions($quiz->id); // Start output. if (!$table->is_downloading()) { // Only print headers if not asked to download data. $this->print_standard_header_and_messages($cm, $course, $quiz, $options, $currentgroup, $hasquestions, $hasstudents); // Print the display options. $this->form->display(); } $hasstudents = $hasstudents && (!$currentgroup || $this->hasgroupstudents); if ($hasquestions && ($hasstudents || $options->attempts == self::ALL_WITH)) { // Construct the SQL. $table->setup_sql_queries($allowedjoins); if (!$table->is_downloading()) { // Print information on the grading method. if ($strattempthighlight = quiz_report_highlighting_grading_method( $quiz, $this->qmsubselect, $options->onlygraded)) { echo '
                ' . $strattempthighlight . '
                '; } } // Define table columns. $columns = []; $headers = []; if (!$table->is_downloading() && $options->checkboxcolumn) { $columnname = 'checkbox'; $columns[] = $columnname; $headers[] = $table->checkbox_col_header($columnname); } $this->add_user_columns($table, $columns, $headers); $this->add_state_column($columns, $headers); $this->add_time_columns($columns, $headers); $this->add_grade_columns($quiz, $options->usercanseegrades, $columns, $headers, false); $this->add_grade_item_columns($options->usercanseegrades, $columns, $headers); $canregrade = has_capability('mod/quiz:regrade', $this->context); if (!$table->is_downloading() && $canregrade && $this->has_regraded_questions($table->sql->from, $table->sql->where, $table->sql->params)) { $columns[] = 'regraded'; $headers[] = get_string('regrade', 'quiz_overview'); } if ($options->slotmarks) { foreach ($questions as $slot => $question) { $columns[] = 'qsgrade' . $slot; $header = get_string('qbrief', 'quiz', $question->displaynumber); if (!$table->is_downloading()) { $header .= '
                '; } else { $header .= ' '; } $header .= '/' . quiz_rescale_grade($question->maxmark, $quiz, 'question'); $headers[] = $header; } } $this->set_up_table_columns($table, $columns, $headers, $this->get_base_url(), $options, false); $table->set_attribute('class', 'generaltable generalbox grades'); $table->out($options->pagesize, true); if ($canregrade && !$table->is_downloading()) { $this->display_commit_regrade_if_required($quiz, $groupstudentsjoins, $options); } } if (!$table->is_downloading() && $options->usercanseegrades) { $output = $PAGE->get_renderer('mod_quiz'); list($bands, $bandwidth) = self::get_bands_count_and_width($quiz); $labels = self::get_bands_labels($bands, $bandwidth, $quiz); if ($currentgroup && $this->hasgroupstudents) { $sql = "SELECT qg.id FROM {quiz_grades} qg JOIN {user} u on u.id = qg.userid {$groupstudentsjoins->joins} WHERE qg.quiz = $quiz->id AND {$groupstudentsjoins->wheres}"; if ($DB->record_exists_sql($sql, $groupstudentsjoins->params)) { $data = quiz_report_grade_bands($bandwidth, $bands, $quiz->id, $groupstudentsjoins); $chart = self::get_chart($labels, $data); $groupname = format_string(groups_get_group_name($currentgroup), true, [ 'context' => $this->context, ]); $graphname = get_string('overviewreportgraphgroup', 'quiz_overview', $groupname); // Numerical range data should display in LTR even for RTL languages. echo $output->chart($chart, $graphname, ['dir' => 'ltr']); } } if ($DB->record_exists('quiz_grades', ['quiz' => $quiz->id])) { $data = quiz_report_grade_bands($bandwidth, $bands, $quiz->id, new \core\dml\sql_join()); $chart = self::get_chart($labels, $data); $graphname = get_string('overviewreportgraph', 'quiz_overview'); // Numerical range data should display in LTR even for RTL languages. echo $output->chart($chart, $graphname, ['dir' => 'ltr']); } } return true; } /** * If a previous dry run regrade had been done, display a message to commit the changes. * * @param stdClass $quiz quiz settings. * @param \core\dml\sql_join $groupstudentsjoins which users' attempts should be considered. * @param quiz_overview_options $options report options. */ protected function display_commit_regrade_if_required( stdClass $quiz, \core\dml\sql_join $groupstudentsjoins, quiz_overview_options $options, ) { global $OUTPUT; [$attemptcount, $slotcount] = $this->count_attempts_and_questions_needing_regrade($quiz, $groupstudentsjoins); if (!$attemptcount) { return; } $commitregradeurl = new moodle_url($options->get_url(), ['sesskey' => sesskey(), 'regradealldrydo' => 1]); // We can't use $OUTPUT->notification because is aggressively cleans the message, which strips the button. echo html_writer::div(get_string('regrade_regradeneedednotificationmessage', 'quiz_overview', ['attempts' => $attemptcount, 'questions' => $slotcount]) . ' ' . $OUTPUT->single_button($commitregradeurl, get_string('regrade_commitregrade', 'quiz_overview')), 'alert alert-info alert-block fade in'); } #[\Override] protected function process_actions($quiz, $cm, $currentgroup, \core\dml\sql_join $groupstudentsjoins, \core\dml\sql_join $allowedjoins, $redirecturl) { parent::process_actions($quiz, $cm, $currentgroup, $groupstudentsjoins, $allowedjoins, $redirecturl); // Process regrade actions. $this->process_regrade_actions($quiz, $cm, $currentgroup, $groupstudentsjoins, $redirecturl); } /** * Extends parent function processing any submitted actions. * * @param stdClass $quiz * @param stdClass $cm * @param int $currentgroup * @param \core\dml\sql_join $groupstudentsjoins (joins, wheres, params) * @param moodle_url $redirecturl */ protected function process_regrade_actions($quiz, $cm, $currentgroup, \core\dml\sql_join $groupstudentsjoins, moodle_url $redirecturl) { if ($currentgroup && !$this->hasgroupstudents) { return; } if (!has_capability('mod/quiz:regrade', $this->context)) { return; } $dryrun = optional_param('dryrunregrade', 0, PARAM_BOOL); if ($dryrun || optional_param('regrade', 0, PARAM_BOOL)) { $attemptids = []; if (optional_param('regradeselectedattempts', 0, PARAM_BOOL)) { $attemptids = optional_param_array('attemptid', [], PARAM_INT); } $slots = null; if (optional_param('regradeselectedquestions', 0, PARAM_BOOL)) { $slots = optional_param_array('regradeslot', [], PARAM_INT); } $this->start_regrade($quiz, $cm); $this->regrade_attempts($quiz, $dryrun, $groupstudentsjoins, $attemptids, $slots); $this->finish_regrade($redirecturl); } // Process commit of a previous dry run. if (optional_param('regradealldrydo', 0, PARAM_BOOL) && confirm_sesskey()) { $this->start_regrade($quiz, $cm); $this->regrade_attempts_needing_it($quiz, $groupstudentsjoins); $this->finish_regrade($redirecturl); } } /** * Check necessary capabilities, and start the display of the regrade progress page. * @param stdClass $quiz the quiz settings. * @param stdClass $cm the cm object for the quiz. */ protected function start_regrade($quiz, $cm) { require_capability('mod/quiz:regrade', $this->context); $this->print_header_and_tabs( $cm, get_course($cm->course), $quiz, $this->mode ); } /** * Finish displaying the regrade progress page. * @param moodle_url $nexturl where to send the user after the regrade. * @uses exit. This method never returns. */ protected function finish_regrade($nexturl) { global $OUTPUT; \core\notification::success(get_string('regradecomplete', 'quiz_overview')); echo $OUTPUT->continue_button($nexturl); echo $OUTPUT->footer(); die(); } /** * Unlock the session and allow the regrading process to run in the background. */ protected function unlock_session() { \core\session\manager::write_close(); ignore_user_abort(true); } /** * Regrade a particular quiz attempt. Either for real ($dryrun = false), or * as a pretend regrade to see which fractions would change. The outcome is * stored in the quiz_overview_regrades table. * * Note, $attempt is not upgraded in the database. The caller needs to do that. * However, $attempt->sumgrades is updated, if this is not a dry run. * * @param stdClass $attempt the quiz attempt to regrade. * @param bool $dryrun if true, do a pretend regrade, otherwise do it for real. * @param array|null $slots if null, regrade all questions, otherwise, just regrade * the questions with those slots. * @return array messages array with keys slot number, and values reasons why that slot cannot be regraded. */ public function regrade_attempt($attempt, $dryrun = false, $slots = null): array { global $DB; // Need more time for a quiz with many questions. core_php_time_limit::raise(300); $transaction = $DB->start_delegated_transaction(); $quba = question_engine::load_questions_usage_by_activity($attempt->uniqueid); $versioninformation = qbank_helper::get_version_information_for_questions_in_attempt( $attempt, $this->context); if (is_null($slots)) { $slots = $quba->get_slots(); } $messages = []; $finished = $attempt->state == quiz_attempt::FINISHED; foreach ($slots as $slot) { $qqr = new stdClass(); $qqr->oldfraction = $quba->get_question_fraction($slot); $otherquestionversion = question_bank::load_question($versioninformation[$slot]->newquestionid); $message = $quba->validate_can_regrade_with_other_version($slot, $otherquestionversion); if ($message) { $messages[$slot] = $message; continue; } $quba->regrade_question($slot, $finished, null, $otherquestionversion); $qqr->newfraction = $quba->get_question_fraction($slot); if (abs($qqr->oldfraction - $qqr->newfraction) > 1e-7) { $qqr->questionusageid = $quba->get_id(); $qqr->slot = $slot; $qqr->regraded = empty($dryrun); $qqr->timemodified = time(); $DB->insert_record('quiz_overview_regrades', $qqr, false); } } if (!$dryrun) { question_engine::save_questions_usage_by_activity($quba); $params = [ 'objectid' => $attempt->id, 'relateduserid' => $attempt->userid, 'context' => $this->context, 'other' => [ 'quizid' => $attempt->quiz ] ]; $event = \mod_quiz\event\attempt_regraded::create($params); $event->trigger(); } $transaction->allow_commit(); // Really, PHP should not need this hint, but without this, we just run out of memory. $quba = null; $transaction = null; gc_collect_cycles(); return $messages; } /** * Regrade attempts for this quiz, exactly which attempts are regraded is * controlled by the parameters. * * @param stdClass $quiz the quiz settings. * @param bool $dryrun if true, do a pretend regrade, otherwise do it for real. * @param \core\dml\sql_join|null $groupstudentsjoins empty for all attempts, otherwise regrade attempts * for these users. * @param array $attemptids blank for all attempts, otherwise only regrade * attempts whose id is in this list. * @param array|null $slots if null, regrade all questions, otherwise, just regrade * the questions with those slots. */ protected function regrade_attempts($quiz, $dryrun = false, ?\core\dml\sql_join $groupstudentsjoins = null, $attemptids = [], ?array $slots = null) { global $DB; $this->unlock_session(); // Get the attempts to regrade. $userfieldsapi = \core_user\fields::for_name(); $sql = "SELECT quiza.*, " . $userfieldsapi->get_sql('u', false, '', '', false)->selects . " FROM {quiz_attempts} quiza JOIN {user} u ON u.id = quiza.userid"; $where = "quiz = :qid AND preview = 0"; $params = ['qid' => $quiz->id]; if ($this->hasgroupstudents && !empty($groupstudentsjoins->joins)) { $sql .= "\n{$groupstudentsjoins->joins}"; $where .= " AND {$groupstudentsjoins->wheres}"; $params += $groupstudentsjoins->params; } if ($attemptids) { list($attemptidcondition, $attemptidparams) = $DB->get_in_or_equal($attemptids, SQL_PARAMS_NAMED); $where .= " AND quiza.id $attemptidcondition"; $params += $attemptidparams; } $sql .= "\nWHERE {$where}"; $attempts = $DB->get_records_sql($sql, $params); if (!$attempts) { return; } // If only regrading some slots, put that information where regrade_batch_of_attempts expects. if ($slots) { foreach ($attempts as $attempt) { $attempt->regradeonlyslots = $slots; } } $this->regrade_batch_of_attempts($quiz, $attempts, $dryrun, $groupstudentsjoins, $slots); } /** * Regrade the questions in the attempts that are marked as needing it in quiz_overview_regrades. * * @param stdClass $quiz the quiz settings. * @param \core\dml\sql_join $groupstudentsjoins empty for all attempts, otherwise regrade attempts * for these users. */ protected function regrade_attempts_needing_it($quiz, \core\dml\sql_join $groupstudentsjoins) { global $DB; $this->unlock_session(); $join = '{quiz_overview_regrades} qqr ON qqr.questionusageid = quiza.uniqueid'; $where = "quiza.quiz = :qid AND quiza.preview = 0 AND qqr.regraded = 0"; $params = ['qid' => $quiz->id]; // Fetch all attempts that need regrading. if ($this->hasgroupstudents && !empty($groupstudentsjoins->joins)) { $join .= "\nJOIN {user} u ON u.id = quiza.userid {$groupstudentsjoins->joins}"; $where .= " AND {$groupstudentsjoins->wheres}"; $params += $groupstudentsjoins->params; } $toregrade = $DB->get_recordset_sql(" SELECT quiza.uniqueid, qqr.slot FROM {quiz_attempts} quiza JOIN $join WHERE $where", $params); $attemptquestions = []; foreach ($toregrade as $row) { $attemptquestions[$row->uniqueid][] = $row->slot; } $toregrade->close(); if (!$attemptquestions) { return; } list($uniqueidcondition, $params) = $DB->get_in_or_equal(array_keys($attemptquestions)); $userfieldsapi = \core_user\fields::for_name(); $attempts = $DB->get_records_sql(" SELECT quiza.*, " . $userfieldsapi->get_sql('u', false, '', '', false)->selects . " FROM {quiz_attempts} quiza JOIN {user} u ON u.id = quiza.userid WHERE quiza.uniqueid $uniqueidcondition ", $params); foreach ($attempts as $attempt) { $attempt->regradeonlyslots = $attemptquestions[$attempt->uniqueid]; } $this->regrade_batch_of_attempts($quiz, $attempts, false, $groupstudentsjoins); } /** * This is a helper used by {@link regrade_attempts()} and * {@link regrade_attempts_needing_it()}. * * Given an array of attempts, it regrades them all, or does a dry run. * Each object in the attempts array must be a row from the quiz_attempts * table, with the \core_user\fields::for_name() fields from the user table joined in. * In addition, if $attempt->regradeonlyslots is set, then only those slots * are regraded, otherwise all slots are regraded. * * @param stdClass $quiz the quiz settings. * @param array $attempts of data from the quiz_attempts table, with extra data as above. * @param bool $dryrun if true, do a pretend regrade, otherwise do it for real. * @param \core\dml\sql_join $groupstudentsjoins empty for all attempts, otherwise regrade attempts */ protected function regrade_batch_of_attempts($quiz, array $attempts, bool $dryrun, \core\dml\sql_join $groupstudentsjoins) { global $OUTPUT; $this->clear_regrade_table($quiz, $groupstudentsjoins); $progressbar = new progress_bar('quiz_overview_regrade', 500, true); $a = [ 'count' => count($attempts), 'done' => 0, ]; foreach ($attempts as $attempt) { $a['done']++; $a['attemptnum'] = $attempt->attempt; $a['name'] = fullname($attempt); $a['attemptid'] = $attempt->id; $progressbar->update($a['done'], $a['count'], get_string('regradingattemptxofywithdetails', 'quiz_overview', $a)); $messages = $this->regrade_attempt($attempt, $dryrun, $attempt->regradeonlyslots ?? null); if ($messages) { $items = []; foreach ($messages as $slot => $message) { $items[] = get_string('regradingattemptissue', 'quiz_overview', ['slot' => $slot, 'reason' => $message]); } echo $OUTPUT->notification( html_writer::tag('p', get_string('regradingattemptxofyproblem', 'quiz_overview', $a)) . html_writer::alist($items), \core\output\notification::NOTIFY_WARNING); } } $progressbar->update($a['done'], $a['count'], get_string('regradedsuccessfullyxofy', 'quiz_overview', $a)); if (!$dryrun) { $this->update_overall_grades($quiz); } } /** * Count the number of attempts and questions in need of regrading after the last dry run. * * @param stdClass $quiz the quiz settings. * @param \core\dml\sql_join $groupstudentsjoins which users' attempts should be considered. * @return array of two elements: the number of different attempts and questions needed to be regraded. */ protected function count_attempts_and_questions_needing_regrade($quiz, \core\dml\sql_join $groupstudentsjoins): array { global $DB; $userjoin = ''; $usertest = ''; $params = []; if ($this->hasgroupstudents) { $userjoin = "JOIN {user} u ON u.id = quiza.userid {$groupstudentsjoins->joins}"; $usertest = "{$groupstudentsjoins->wheres} AND u.id = quiza.userid AND "; $params = $groupstudentsjoins->params; } $params['cquiz'] = $quiz->id; $sql = "SELECT COUNT(DISTINCT quiza.id) AS attemptcount, COUNT(DISTINCT qqr.slot) AS slotcount FROM {quiz_attempts} quiza JOIN {quiz_overview_regrades} qqr ON quiza.uniqueid = qqr.questionusageid $userjoin WHERE $usertest quiza.quiz = :cquiz AND quiza.preview = 0 AND qqr.regraded = 0"; $counts = $DB->get_record_sql($sql, $params); return [$counts->attemptcount, $counts->slotcount]; } /** * Are there any pending regrades in the table we are going to show? * @param string $from tables used by the main query. * @param string $where where clause used by the main query. * @param array $params required by the SQL. * @return bool whether there are pending regrades. */ protected function has_regraded_questions($from, $where, $params) { global $DB; return $DB->record_exists_sql(" SELECT 1 FROM {$from} JOIN {quiz_overview_regrades} qor ON qor.questionusageid = quiza.uniqueid WHERE {$where}", $params); } /** * Remove all information about pending/complete regrades from the database. * @param stdClass $quiz the quiz settings. * @param \core\dml\sql_join $groupstudentsjoins (joins, wheres, params). If this is given, only data relating * to these users is cleared. */ protected function clear_regrade_table($quiz, \core\dml\sql_join $groupstudentsjoins) { global $DB; // Fetch all attempts that need regrading. $select = "questionusageid IN ( SELECT uniqueid FROM {quiz_attempts} quiza"; $where = "WHERE quiza.quiz = :qid"; $params = ['qid' => $quiz->id]; if ($this->hasgroupstudents && !empty($groupstudentsjoins->joins)) { $select .= "\nJOIN {user} u ON u.id = quiza.userid {$groupstudentsjoins->joins}"; $where .= " AND {$groupstudentsjoins->wheres}"; $params += $groupstudentsjoins->params; } $select .= "\n$where)"; $DB->delete_records_select('quiz_overview_regrades', $select, $params); } /** * Update the final grades for all attempts. This method is used following a regrade. * * @param stdClass $quiz the quiz settings. */ protected function update_overall_grades($quiz) { $gradecalculator = $this->quizobj->get_grade_calculator(); $gradecalculator->recompute_all_attempt_sumgrades(); $gradecalculator->recompute_all_final_grades(); quiz_update_grades($quiz); } /** * Get the bands configuration for the quiz. * * This returns the configuration for having between 11 and 20 bars in * a chart based on the maximum grade to be given on a quiz. The width of * a band is the number of grade points it encapsulates. * * @param stdClass $quiz The quiz object. * @return array Contains the number of bands, and their width. */ public static function get_bands_count_and_width($quiz) { $bands = $quiz->grade; while ($bands > 20 || $bands <= 10) { if ($bands > 50) { $bands /= 5; } else if ($bands > 20) { $bands /= 2; } if ($bands < 4) { $bands *= 5; } else if ($bands <= 10) { $bands *= 2; } } // See MDL-34589. Using doubles as array keys causes problems in PHP 5.4, hence the explicit cast to int. $bands = (int) ceil($bands); return [$bands, $quiz->grade / $bands]; } /** * Get the bands labels. * * @param int $bands The number of bands. * @param int $bandwidth The band width. * @param stdClass $quiz The quiz object. * @return string[] The labels. */ public static function get_bands_labels($bands, $bandwidth, $quiz) { $bandlabels = []; for ($i = 1; $i <= $bands; $i++) { $bandlabels[] = quiz_format_grade($quiz, ($i - 1) * $bandwidth) . ' - ' . quiz_format_grade($quiz, $i * $bandwidth); } return $bandlabels; } /** * Get a chart. * * @param string[] $labels Chart labels. * @param int[] $data The data. * @return \core\chart_base */ protected static function get_chart($labels, $data) { $chart = new \core\chart_bar(); $chart->set_labels($labels); $chart->get_xaxis(0, true)->set_label(get_string('gradenoun')); $yaxis = $chart->get_yaxis(0, true); $yaxis->set_label(get_string('participants')); $yaxis->set_stepsize(max(1, round(max($data) / 10))); $series = new \core\chart_series(get_string('participants'), $data); $chart->add_series($series); return $chart; } } PK9B\WS(report/overview/amd/src/regrade_modal.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 . /** * Regrade modal form is used to regrade or dryrun the attempts and questions. * * @module quiz_overview/regrade_modal * @copyright 2024 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ import Modal from 'core/modal'; import Templates from 'core/templates'; import {getString} from 'core/str'; import Notification from 'core/notification'; /** * @type {Object} selectors used in this code. */ const SELECTORS = { 'allQuestionsButton': '#regradeallquestions', 'dryRunButton': '#dryrunbutton', 'mainTableForm': '#attemptsform', 'questionCheckbox': '[id^="regradeslot"]', 'regradeAttemptsButtonId': 'regradeattempts', 'regradeButton': '#regradebutton', 'reportTableSelectedAttempts': '[id^="attemptid_"]:checked', }; /** * Show the regrade modal. * * @param {Event} e click event that opened the modal. * @returns {Promise} */ const showModal = async(e) => { e.preventDefault(); try { let hiddenInputs = []; document.querySelectorAll(SELECTORS.mainTableForm + ' input[type=hidden]').forEach((hiddenInput) => { hiddenInputs.push({'name': hiddenInput.name, 'value': hiddenInput.value}); }); document.querySelectorAll(SELECTORS.reportTableSelectedAttempts).forEach((selectedAttempt) => { hiddenInputs.push({'name': selectedAttempt.name, 'value': selectedAttempt.value}); }); const modal = await Modal.create({ title: getString('regrade', 'quiz_overview'), body: Templates.render('quiz_overview/regrade_modal_body', { 'actionurl': document.querySelector(SELECTORS.mainTableForm).action, 'hasselectedattempts': document.querySelector(SELECTORS.reportTableSelectedAttempts) !== null, 'questions': JSON.parse(document.getElementById(SELECTORS.regradeAttemptsButtonId).dataset.slots), 'hiddeninputs': hiddenInputs, }), isVerticallyCentered: true, removeOnClose: true, show: true, }); modal.getRoot()[0].addEventListener('change', updateButtonStates); modal.getBodyPromise().then(updateButtonStates).catch(Notification.exception); // TODO MDL-82204 - there is not currently a good way to add a help icon to a modal overall, so we do it this way. modal.getTitlePromise().then((title) => { title.append(' ' + document.getElementById(SELECTORS.regradeAttemptsButtonId).dataset.helpIcon); // The next line is necessary to get a nice layout of the help icon. title[0].querySelector('a').classList.add('align-baseline'); return title[0]; }).catch(Notification.exception); } catch (ex) { await Notification.exception(ex); } }; /** * Enables and disables controls when the selected options are changed. */ const updateButtonStates = () => { const allQuestionsButton = document.querySelector(SELECTORS.allQuestionsButton); // Question checkboxes enabled only if Selected questions is checked. document.querySelectorAll(SELECTORS.questionCheckbox).forEach((questionCheckbox) => { questionCheckbox.disabled = allQuestionsButton.checked; }); // State of submit buttons. const isAnyQuestionSelected = document.querySelector(SELECTORS.questionCheckbox + ':checked') !== null; const canSubmit = allQuestionsButton.checked || isAnyQuestionSelected; document.querySelector(SELECTORS.regradeButton).disabled = !canSubmit; document.querySelector(SELECTORS.dryRunButton).disabled = !canSubmit; }; /** * Initialize the regrade button to open the modal. */ export const init = () => { const regradeButton = document.getElementById(SELECTORS.regradeAttemptsButtonId); if (!regradeButton) { return; } regradeButton.addEventListener('click', showModal); }; PK9B\%Q2report/overview/amd/build/regrade_modal.min.js.mapnu[{"version":3,"file":"regrade_modal.min.js","sources":["../src/regrade_modal.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 * Regrade modal form is used to regrade or dryrun the attempts and questions.\n *\n * @module quiz_overview/regrade_modal\n * @copyright 2024 The Open University\n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport Modal from 'core/modal';\nimport Templates from 'core/templates';\nimport {getString} from 'core/str';\nimport Notification from 'core/notification';\n\n/**\n * @type {Object} selectors used in this code.\n */\nconst SELECTORS = {\n 'allQuestionsButton': '#regradeallquestions',\n 'dryRunButton': '#dryrunbutton',\n 'mainTableForm': '#attemptsform',\n 'questionCheckbox': '[id^=\"regradeslot\"]',\n 'regradeAttemptsButtonId': 'regradeattempts',\n 'regradeButton': '#regradebutton',\n 'reportTableSelectedAttempts': '[id^=\"attemptid_\"]:checked',\n};\n\n/**\n * Show the regrade modal.\n *\n * @param {Event} e click event that opened the modal.\n * @returns {Promise}\n */\nconst showModal = async(e) => {\n e.preventDefault();\n try {\n let hiddenInputs = [];\n document.querySelectorAll(SELECTORS.mainTableForm + ' input[type=hidden]').forEach((hiddenInput) => {\n hiddenInputs.push({'name': hiddenInput.name, 'value': hiddenInput.value});\n });\n document.querySelectorAll(SELECTORS.reportTableSelectedAttempts).forEach((selectedAttempt) => {\n hiddenInputs.push({'name': selectedAttempt.name, 'value': selectedAttempt.value});\n });\n const modal = await Modal.create({\n title: getString('regrade', 'quiz_overview'),\n body: Templates.render('quiz_overview/regrade_modal_body', {\n 'actionurl': document.querySelector(SELECTORS.mainTableForm).action,\n 'hasselectedattempts': document.querySelector(SELECTORS.reportTableSelectedAttempts) !== null,\n 'questions': JSON.parse(document.getElementById(SELECTORS.regradeAttemptsButtonId).dataset.slots),\n 'hiddeninputs': hiddenInputs,\n }),\n isVerticallyCentered: true,\n removeOnClose: true,\n show: true,\n });\n modal.getRoot()[0].addEventListener('change', updateButtonStates);\n modal.getBodyPromise().then(updateButtonStates).catch(Notification.exception);\n\n // TODO MDL-82204 - there is not currently a good way to add a help icon to a modal overall, so we do it this way.\n modal.getTitlePromise().then((title) => {\n title.append(' ' + document.getElementById(SELECTORS.regradeAttemptsButtonId).dataset.helpIcon);\n // The next line is necessary to get a nice layout of the help icon.\n title[0].querySelector('a').classList.add('align-baseline');\n return title[0];\n }).catch(Notification.exception);\n } catch (ex) {\n await Notification.exception(ex);\n }\n};\n\n/**\n * Enables and disables controls when the selected options are changed.\n */\nconst updateButtonStates = () => {\n const allQuestionsButton = document.querySelector(SELECTORS.allQuestionsButton);\n\n // Question checkboxes enabled only if Selected questions is checked.\n document.querySelectorAll(SELECTORS.questionCheckbox).forEach((questionCheckbox) => {\n questionCheckbox.disabled = allQuestionsButton.checked;\n });\n\n // State of submit buttons.\n const isAnyQuestionSelected = document.querySelector(SELECTORS.questionCheckbox + ':checked') !== null;\n const canSubmit = allQuestionsButton.checked || isAnyQuestionSelected;\n document.querySelector(SELECTORS.regradeButton).disabled = !canSubmit;\n document.querySelector(SELECTORS.dryRunButton).disabled = !canSubmit;\n};\n\n/**\n * Initialize the regrade button to open the modal.\n */\nexport const init = () => {\n const regradeButton = document.getElementById(SELECTORS.regradeAttemptsButtonId);\n if (!regradeButton) {\n return;\n }\n\n regradeButton.addEventListener('click', showModal);\n};\n"],"names":["SELECTORS","showModal","async","e","preventDefault","hiddenInputs","document","querySelectorAll","forEach","hiddenInput","push","name","value","selectedAttempt","modal","Modal","create","title","body","Templates","render","querySelector","action","JSON","parse","getElementById","dataset","slots","isVerticallyCentered","removeOnClose","show","getRoot","addEventListener","updateButtonStates","getBodyPromise","then","catch","Notification","exception","getTitlePromise","append","helpIcon","classList","add","ex","allQuestionsButton","questionCheckbox","disabled","checked","isAnyQuestionSelected","canSubmit","regradeButton"],"mappings":";;;;;;;gOA+BMA,6BACoB,uBADpBA,uBAEc,gBAFdA,wBAGe,gBAHfA,2BAIkB,sBAJlBA,kCAKyB,kBALzBA,wBAMe,iBANfA,sCAO6B,6BAS7BC,UAAYC,MAAAA,IACdC,EAAEC,yBAEMC,aAAe,GACnBC,SAASC,iBAAiBP,wBAA0B,uBAAuBQ,SAASC,cAChFJ,aAAaK,KAAK,MAASD,YAAYE,WAAeF,YAAYG,WAEtEN,SAASC,iBAAiBP,uCAAuCQ,SAASK,kBACtER,aAAaK,KAAK,MAASG,gBAAgBF,WAAeE,gBAAgBD,iBAExEE,YAAcC,eAAMC,OAAO,CAC7BC,OAAO,kBAAU,UAAW,iBAC5BC,KAAMC,mBAAUC,OAAO,mCAAoC,WAC1Cd,SAASe,cAAcrB,yBAAyBsB,2BAC4B,OAAlEhB,SAASe,cAAcrB,iDACjCuB,KAAKC,MAAMlB,SAASmB,eAAezB,mCAAmC0B,QAAQC,oBAC3EtB,eAEpBuB,sBAAsB,EACtBC,eAAe,EACfC,MAAM,IAEVhB,MAAMiB,UAAU,GAAGC,iBAAiB,SAAUC,oBAC9CnB,MAAMoB,iBAAiBC,KAAKF,oBAAoBG,MAAMC,sBAAaC,WAGnExB,MAAMyB,kBAAkBJ,MAAMlB,QAC1BA,MAAMuB,OAAO,IAAMlC,SAASmB,eAAezB,mCAAmC0B,QAAQe,UAEtFxB,MAAM,GAAGI,cAAc,KAAKqB,UAAUC,IAAI,kBACnC1B,MAAM,MACdmB,MAAMC,sBAAaC,WACxB,MAAOM,UACCP,sBAAaC,UAAUM,MAO/BX,mBAAqB,WACjBY,mBAAqBvC,SAASe,cAAcrB,8BAGlDM,SAASC,iBAAiBP,4BAA4BQ,SAASsC,mBAC3DA,iBAAiBC,SAAWF,mBAAmBG,iBAI7CC,sBAA4F,OAApE3C,SAASe,cAAcrB,2BAA6B,YAC5EkD,UAAYL,mBAAmBG,SAAWC,sBAChD3C,SAASe,cAAcrB,yBAAyB+C,UAAYG,UAC5D5C,SAASe,cAAcrB,wBAAwB+C,UAAYG,yBAM3C,WACVC,cAAgB7C,SAASmB,eAAezB,mCACzCmD,eAILA,cAAcnB,iBAAiB,QAAS/B"}PK9B\;,D D .report/overview/amd/build/regrade_modal.min.jsnu[define("quiz_overview/regrade_modal",["exports","core/modal","core/templates","core/str","core/notification"],(function(_exports,_modal,_templates,_str,_notification){function _interopRequireDefault(obj){return obj&&obj.__esModule?obj:{default:obj}} /** * Regrade modal form is used to regrade or dryrun the attempts and questions. * * @module quiz_overview/regrade_modal * @copyright 2024 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.init=void 0,_modal=_interopRequireDefault(_modal),_templates=_interopRequireDefault(_templates),_notification=_interopRequireDefault(_notification);const SELECTORS_allQuestionsButton="#regradeallquestions",SELECTORS_dryRunButton="#dryrunbutton",SELECTORS_mainTableForm="#attemptsform",SELECTORS_questionCheckbox='[id^="regradeslot"]',SELECTORS_regradeAttemptsButtonId="regradeattempts",SELECTORS_regradeButton="#regradebutton",SELECTORS_reportTableSelectedAttempts='[id^="attemptid_"]:checked',showModal=async e=>{e.preventDefault();try{let hiddenInputs=[];document.querySelectorAll(SELECTORS_mainTableForm+" input[type=hidden]").forEach((hiddenInput=>{hiddenInputs.push({name:hiddenInput.name,value:hiddenInput.value})})),document.querySelectorAll(SELECTORS_reportTableSelectedAttempts).forEach((selectedAttempt=>{hiddenInputs.push({name:selectedAttempt.name,value:selectedAttempt.value})}));const modal=await _modal.default.create({title:(0,_str.getString)("regrade","quiz_overview"),body:_templates.default.render("quiz_overview/regrade_modal_body",{actionurl:document.querySelector(SELECTORS_mainTableForm).action,hasselectedattempts:null!==document.querySelector(SELECTORS_reportTableSelectedAttempts),questions:JSON.parse(document.getElementById(SELECTORS_regradeAttemptsButtonId).dataset.slots),hiddeninputs:hiddenInputs}),isVerticallyCentered:!0,removeOnClose:!0,show:!0});modal.getRoot()[0].addEventListener("change",updateButtonStates),modal.getBodyPromise().then(updateButtonStates).catch(_notification.default.exception),modal.getTitlePromise().then((title=>(title.append(" "+document.getElementById(SELECTORS_regradeAttemptsButtonId).dataset.helpIcon),title[0].querySelector("a").classList.add("align-baseline"),title[0]))).catch(_notification.default.exception)}catch(ex){await _notification.default.exception(ex)}},updateButtonStates=()=>{const allQuestionsButton=document.querySelector(SELECTORS_allQuestionsButton);document.querySelectorAll(SELECTORS_questionCheckbox).forEach((questionCheckbox=>{questionCheckbox.disabled=allQuestionsButton.checked}));const isAnyQuestionSelected=null!==document.querySelector(SELECTORS_questionCheckbox+":checked"),canSubmit=allQuestionsButton.checked||isAnyQuestionSelected;document.querySelector(SELECTORS_regradeButton).disabled=!canSubmit,document.querySelector(SELECTORS_dryRunButton).disabled=!canSubmit};_exports.init=()=>{const regradeButton=document.getElementById(SELECTORS_regradeAttemptsButtonId);regradeButton&®radeButton.addEventListener("click",showModal)}})); //# sourceMappingURL=regrade_modal.min.js.mapPK9B\\'!report/overview/tests/helpers.phpnu[. use mod_quiz\local\reports\attempts_report; /** * Makes some protected methods of attempts_report public to facilitate testing. * * @package quiz_overview * @copyright 2020 Huong Nguyen * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ /** * Makes some protected methods of attempts_report public to facilitate testing. * * @copyright 2020 Huong Nguyen * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class testable_quiz_attempts_report extends attempts_report { /** * Override this function to displays the report. * @param stdClass $cm the course-module for this quiz. * @param stdClass $course the course we are in. * @param stdClass $quiz this quiz. */ public function display($cm, $course, $quiz) { } /** * Testable delete_selected_attempts function. * * @param stdClass $quiz * @param stdClass $cm * @param array $attemptids * @param \core\dml\sql_join $allowedjoins */ public function delete_selected_attempts($quiz, $cm, $attemptids, \core\dml\sql_join $allowedjoins) { parent::delete_selected_attempts($quiz, $cm, $attemptids, $allowedjoins); } } PK9B\՜XX/report/overview/tests/privacy/provider_test.phpnu[. /** * Privacy provider tests. * * @package quiz_overview * @copyright 2018 Andrew Nicols * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace quiz_overview\privacy; use core_privacy\local\metadata\collection; use quiz_overview\privacy\provider; use core_privacy\local\request\writer; use core_privacy\local\request\transform; defined('MOODLE_INTERNAL') || die(); /** * Privacy provider tests class. * * @package quiz_overview * @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 { /** * When no preference exists, there should be no export. */ public function test_preference_unset(): void { global $USER; $this->resetAfterTest(); $this->setAdminUser(); provider::export_user_preferences($USER->id); $this->assertFalse(writer::with_context(\context_system::instance())->has_any_data()); } /** * Preference does exist. */ public function test_preference_yes(): void { $this->resetAfterTest(); // Create test user, add some preferences. $user = $this->getDataGenerator()->create_user(); $this->setUser($user); set_user_preference('quiz_overview_slotmarks', 1, $user); // Switch to admin user (so we can validate preferences of the correct user are being exported). $this->setAdminUser(); // Export test users preferences. provider::export_user_preferences($user->id); $writer = writer::with_context(\context_system::instance()); $this->assertTrue($writer->has_any_data()); $preferences = $writer->get_user_preferences('quiz_overview'); $this->assertNotEmpty($preferences->slotmarks); $this->assertEquals(transform::yesno(1), $preferences->slotmarks->value); $description = get_string('privacy:preference:slotmarks:yes', 'quiz_overview'); $this->assertEquals($description, $preferences->slotmarks->description); } /** * Preference does exist and is no. */ public function test_preference_no(): void { $this->resetAfterTest(); // Create test user, add some preferences. $user = $this->getDataGenerator()->create_user(); $this->setUser($user); set_user_preference('quiz_overview_slotmarks', 0); // Switch to admin user (so we can validate preferences of the correct user are being exported). $this->setAdminUser(); // Export test users preferences. provider::export_user_preferences($user->id); $writer = writer::with_context(\context_system::instance()); $this->assertTrue($writer->has_any_data()); $preferences = $writer->get_user_preferences('quiz_overview'); $this->assertNotEmpty($preferences->slotmarks); $this->assertEquals(transform::yesno(0), $preferences->slotmarks->value); $description = get_string('privacy:preference:slotmarks:no', 'quiz_overview'); $this->assertEquals($description, $preferences->slotmarks->description); } } PK9B\`2 2report/overview/tests/behat/reopen_attempt.featurenu[@mod @mod_quiz @quiz @quiz_overview @javascript Feature: Reopening Never submitted quiz attempts In order to cut some slack to students who forgot to submit their quiz attempt As a teacher I need to be able to reopen selected attempts. Background: Given the following "users" exist: | username | firstname | lastname | | teacher | Mark | Allwright | | student | Freddy | Forgetful | 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 | | Test questions | truefalse | TF | And the following "activities" exist: | activity | name | course | idnumber | | quiz | Test quiz | C1 | quiz1 | And quiz "Test quiz" contains the following questions: | question | page | | TF | 1 | And user "student" has started an attempt at quiz "Test quiz" And the attempt at "Test quiz" by "student" was never submitted Scenario: Attempt can be reopened Given I am on the "Test quiz" "mod_quiz > Grades report" page logged in as teacher When I press "Reopen" And I should see "This will reopen attempt 1 by Freddy Forgetful." in the "Reopen attempt?" "dialogue" And I should see "The attempt will remain open and can be continued." in the "Reopen attempt?" "dialogue" And I click on "Reopen" "button" in the "Reopen attempt?" "dialogue" Then I should see "In progress" in the "Freddy Forgetful" "table_row" And "Reopen" "button" should not exist Scenario: Reopening an attempt can be cancelled and then nothing happens Given I am on the "Test quiz" "mod_quiz > Grades report" page logged in as teacher And I start watching to see if a new page loads When I press "Reopen" And I click on "Cancel" "button" in the "Reopen attempt?" "dialogue" Then a new page should not have loaded since I started watching And I should see "Never submitted" in the "Freddy Forgetful" "table_row" PK9B\ RzEzE-report/overview/tests/behat/regrading.featurenu[@mod @mod_quiz @quiz @quiz_overview @javascript Feature: Regrading quiz attempts using the Grades report In order to be able to correct mistakes I made setting up my quiz As a teacher I need to be able to re-grade attempts after editing questions Background: Given the following "users" exist: | username | firstname | lastname | | teacher | Mark | Allwright | | student1 | Student | One | | student2 | Student | Two | | student3 | Student | Three | And the following "courses" exist: | fullname | shortname | category | | Course 1 | C1 | 0 | And the following "course enrolments" exist: | user | course | role | | teacher | C1 | editingteacher | | student1 | C1 | student | | student2 | C1 | student | | student3 | C1 | student | And the following "activities" exist: | activity | name | course | idnumber | | quiz | Quiz for testing regrading | 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 | TF | | Test questions | shortanswer | SA | And quiz "Quiz for testing regrading" contains the following questions: | question | page | maxmark | | TF | 1 | 5.0 | | SA | 1 | 5.0 | And user "student1" has attempted "Quiz for testing regrading" with responses: | slot | response | | 1 | True | | 2 | frog | And user "student2" has attempted "Quiz for testing regrading" with responses: | slot | response | | 1 | True | | 2 | toad | Scenario: Regrade all attempts and all questions. Given I am on the "Quiz for testing regrading" "quiz activity" page logged in as teacher And I navigate to "Results" in current page administration And I press "Regrade attempts..." And I click on "Regrade now" "button" in the "Regrade" "dialogue" # Note, the order is not defined, so we can only check part of the message. # Also, nothing has changed in the quiz, so the regrade won't alter any scores, # but this is still a useful test that the regrade process completes without errors. Then I should see "Quiz for testing regrading" And I should see "Finished regrading (2/2)" And I should see "Regrade completed" And I press "Continue" # These next tests just serve to check we got back to the report. And I should see "Quiz for testing regrading" And I should see "Overall number of students achieving grade ranges" Scenario: Regrade selected attempts and all questions. Given I am on the "Quiz for testing regrading" "quiz activity" page logged in as teacher And I navigate to "Results" in current page administration When I click on "Select attempt" "checkbox" in the "Student Two" "table_row" And I press "Regrade attempts..." And I click on "Regrade now" "button" in the "Regrade" "dialogue" Then I should see "Quiz for testing regrading" And I should see "Finished regrading (1/1)" And I should see "Regrade completed" And I press "Continue" # These next tests just serve to check we got back to the report. And I should see "Quiz for testing regrading" And I should see "Overall number of students achieving grade ranges" Scenario: Regrade all attempts and selected questions. Given I am on the "Quiz for testing regrading" "mod_quiz > edit" page logged in as teacher When I follow "Edit question SA" And I set the field "id_fraction_1" to "50%" And I press "id_submitbutton" And I follow "Edit question TF" And I set the field "Correct answer" to "False" And I press "id_submitbutton" And I follow "Attempts: 2" And I press "Regrade attempts..." And I click on "Selected questions" "radio" And I click on "Question 1" "checkbox" And I click on "Regrade now" "button" in the "Regrade" "dialogue" Then I should see "Quiz for testing regrading" And I should see "Finished regrading (2/2)" And I should see "Regrade completed" And I press "Continue" And "Student OneReview attempt" row "Q. 1/50.00" column of "attempts" table should contain "50.00/0.00" And "Student TwoReview attempt" row "Q. 1/50.00" column of "attempts" table should contain "50.00/0.00" And "Student OneReview attempt" row "Grade/100.00" column of "attempts" table should contain "100.00/50.00" And "Student TwoReview attempt" row "Grade/100.00" column of "attempts" table should contain "90.00/40.00" Scenario: Regrade selected attempts and selected questions. Given I am on the "Quiz for testing regrading" "mod_quiz > edit" page logged in as teacher When I follow "Edit question SA" And I set the field "id_fraction_1" to "50%" And I press "id_submitbutton" And I follow "Edit question TF" And I set the field "Correct answer" to "False" And I press "id_submitbutton" And I follow "Attempts: 2" And I click on "Select attempt" "checkbox" in the "Student Two" "table_row" And I press "Regrade attempts..." And the "Question 1" "checkbox" should be disabled And the "Question 2" "checkbox" should be disabled And I click on "Selected questions" "radio" And the "Question 1" "checkbox" should be enabled And the "Question 2" "checkbox" should be enabled And the "Regrade now" "button" should be disabled And the "Dry run" "button" should be disabled And I click on "Question 1" "checkbox" And the "Regrade now" "button" should be enabled And the "Dry run" "button" should be enabled And I click on "Regrade now" "button" in the "Regrade" "dialogue" Then I should see "Quiz for testing regrading" And I should see "Finished regrading (1/1)" And I should see "Regrade completed" And I press "Continue" And "Student TwoReview attempt" row "Q. 1/50.00" column of "attempts" table should contain "50.00/0.00" And "Student TwoReview attempt" row "Grade/100.00" column of "attempts" table should contain "90.00/40.00" Scenario: Dry-run all attempts, then regrade all attempts. Given I am on the "Quiz for testing regrading" "mod_quiz > edit" page logged in as teacher And I follow "Edit question SA" And I set the field "id_fraction_1" to "50%" And I press "id_submitbutton" And I set the field "version" in the "TF" "list_item" to "v1" And I set the field "version" in the "SA" "list_item" to "v2 (latest)" And I follow "Attempts: 2" And I press "Regrade attempts..." And I click on "Dry run" "button" in the "Regrade" "dialogue" # Note, the order is not defined, so we can only check part of the message. Then I should see "Quiz for testing regrading" And I should see "Finished regrading (2/2)" And I should see "Regrade completed" And I press "Continue" And "Student One" row "Regrade" column of "attempts" table should not contain "Needed" And "Student TwoReview attempt" row "Regrade" column of "attempts" table should contain "Needed" # In the following, the first number is strike-through, and the second is not, but Behat can't see that. # At this point, it is showing what would change. And "Student TwoReview attempt" row "Q. 2/50.00" column of "attempts" table should contain "40.00/25.00" And "Student TwoReview attempt" row "Grade/100.00" column of "attempts" table should contain "90.00/75.00" And I press "Regrade attempts..." And I click on "Regrade now" "button" in the "Regrade" "dialogue" And I should see "Quiz for testing regrading" And I should see "Finished regrading (2/2)" And I should see "Regrade completed" And I press "Continue" # These next tests just serve to check we got back to the report. And I should see "Quiz for testing regrading" And I should see "Overall number of students achieving grade ranges" # Now, both old-score strike-through and new score plain, are still shown, but now it indicates what did change. And "Student TwoReview attempt" row "Q. 2/50.00" column of "attempts" table should contain "40.00/25.00" And "Student TwoReview attempt" row "Grade/100.00" column of "attempts" table should contain "90.00/75.00" Scenario: Dry-run a full regrade, then regrade the commit regrade. Given I am on the "Quiz for testing regrading" "mod_quiz > edit" page logged in as teacher When I follow "Edit question SA" And I set the field "id_fraction_1" to "50%" And I press "id_submitbutton" And I set the field "version" in the "TF" "list_item" to "v1" And I set the field "version" in the "SA" "list_item" to "v2 (latest)" And I follow "Attempts: 2" And I press "Regrade attempts..." And I click on "Dry run" "button" in the "Regrade" "dialogue" # Note, the order is not defined, so we can only check part of the message. And I should see "Quiz for testing regrading" And I should see "Finished regrading (2/2)" And I should see "Regrade completed" And I press "Continue" And "Student One" row "Regrade" column of "attempts" table should not contain "Needed" And "Student TwoReview attempt" row "Regrade" column of "attempts" table should contain "Needed" # In the following, the first number is strike-through, and the second is not, but Behat can't see that. # At this point, it is showing what would change. And "Student TwoReview attempt" row "Q. 2/50.00" column of "attempts" table should contain "40.00/25.00" And "Student TwoReview attempt" row "Grade/100.00" column of "attempts" table should contain "90.00/75.00" Then I should see "The last dry run of a regrade found that the regrade would change the marks for 1 questions in 1 attempts." And I press "Commit regrade" And I should see "Quiz for testing regrading" And I should see "Finished regrading (1/1)" And I should see "Regrade completed" And I press "Continue" # These next tests just serve to check we got back to the report. And I should see "Quiz for testing regrading" And I should see "Overall number of students achieving grade ranges" # Now, both old-score strike-through and new score plain, are still shown, but now it indicates what did change. And "Student TwoReview attempt" row "Q. 2/50.00" column of "attempts" table should contain "40.00/25.00" And "Student TwoReview attempt" row "Grade/100.00" column of "attempts" table should contain "90.00/75.00" And I should not see "The last dry run of a regrade found that the regrade would change the marks for 1 questions in 1 attempts." Scenario: Regrade all attempts works against quiz selected question version Given I am on the "Quiz for testing regrading" "quiz activity" page logged in as teacher And I navigate to "Results" in current page administration And I press "Regrade attempts..." And I click on "Dry run" "button" in the "Regrade" "dialogue" Then I should see "Quiz for testing regrading" And I should see "Finished regrading (2/2)" And I should see "Regrade completed" And I press "Continue" And I should see "Quiz for testing regrading" And I should see "Overall number of students achieving grade ranges" And "Student One" row "Regrade" column of "attempts" table should not contain "Needed" And I am on the "Quiz for testing regrading" "mod_quiz > question bank" page And I apply question bank filter "Category" with value "Test questions" And I choose "Edit question" action for "TF" in the question bank And I set the field "Correct answer" to "False" And I press "id_submitbutton" And I am on the "Quiz for testing regrading" "mod_quiz > edit" page And I set the field "version" in the "TF" "list_item" to "v2 (latest)" And I navigate to "Results" in current page administration And I press "Regrade attempts..." And I click on "Dry run" "button" in the "Regrade" "dialogue" And I should see "Regrade completed" And I press "Continue" And "student1@example.com" row "Regrade" column of "attempts" table should contain "Needed" And "Correct" "icon" should appear before "50.00/0.00" "text" And I press "Regrade attempts..." And I click on "Regrade now" "button" in the "Regrade" "dialogue" And I should see "Regrade completed" And I press "Continue" Then "student1@example.com" row "Regrade" column of "attempts" table should contain "Done" And "Student OneReview attempt" row "Q. 1/50.00" column of "attempts" table should contain "50.00/0.00" And "Incorrect" "icon" should appear before "50.00/0.00" "text" Scenario: Regrade all attempts works against quiz selected latest question version Given I am on the "Quiz for testing regrading" "quiz activity" page logged in as teacher And I navigate to "Results" in current page administration And I click on "mod-quiz-report-overview-report-selectall-attempts" "checkbox" And I click on "Delete selected attempts" "button" And I click on "Yes" "button" And I am on the "Quiz for testing regrading" "mod_quiz > edit" page And I should see "(latest)" in the "TF" "list_item" # Create multiple question versions. And I am on the "Quiz for testing regrading" "mod_quiz > question bank" page And I apply question bank filter "Category" with value "Test questions" And I choose "Edit question" action for "TF" in the question bank And I set the field "Correct answer" to "True" And I press "id_submitbutton" And I choose "Edit question" action for "TF" in the question bank And I set the field "Question name" to "New version of TF" And I set the field "Correct answer" to "False" And I press "id_submitbutton" And I am on the "Quiz for testing regrading" "mod_quiz > edit" page And I should see "(latest)" in the "TF" "list_item" And I click on "version" "select" in the "TF" "list_item" And I should see "v1" And I should see "v2" And I should see "v3 (latest)" # Set version that is going to be attempted to an older one. And I set the field "version" in the "TF" "list_item" to "v1" And user "student3" has attempted "Quiz for testing regrading" with responses: | slot | response | | 1 | True | | 2 | toad | And I am on the "Quiz for testing regrading" "mod_quiz > edit" page And I set the field "version" in the "TF" "list_item" to "Always latest" And I navigate to "Results" in current page administration And I press "Regrade attempts..." And I click on "Regrade now" "button" in the "Regrade" "dialogue" And I should see "Finished regrading (1/1)" And I should see "Regrade completed" And I press "Continue" Then "student3@example.com" row "Q. 1/50.00" column of "attempts" table should contain "50.00/0.00" And "Incorrect" "icon" should appear before "50.00/0.00" "text" Scenario: Regrade attempts should always regrade against latest random question version Given I am on the "Quiz for testing regrading" "quiz activity" page logged in as teacher And I navigate to "Results" in current page administration And I click on "mod-quiz-report-overview-report-selectall-attempts" "checkbox" And I click on "Delete selected attempts" "button" And I click on "Yes" "button" # Create multiple question versions. And I am on the "Quiz for testing regrading" "mod_quiz > question bank" page And I apply question bank filter "Category" with value "Test questions" And I choose "Delete" action for "SA" in the question bank And I press "Delete" And I am on the "Quiz for testing regrading" "mod_quiz > edit" page And I click on "Delete" "link" in the "TF" "list_item" And I click on "Yes" "button" in the "Confirm" "dialogue" And I click on "Delete" "link" in the "SA" "list_item" And I click on "Yes" "button" in the "Confirm" "dialogue" And I click on "Add" "link" And I follow "a random question" And I apply question bank filter "Category" with value "Test questions" And I press "Add random question" And I am on the "Quiz for testing regrading" "quiz activity" page logged in as student3 And I click on "Attempt quiz" "button" And I should see "The answer is true." And I set the field "True" to "1" And I click on "Finish attempt ..." "button" And I press "Submit all and finish" And I click on "Submit" "button" in the "Submit all your answers and finish?" "dialogue" And I am on the "Quiz for testing regrading" "mod_quiz > question bank" page logged in as teacher And I apply question bank filter "Category" with value "Test questions" And I choose "Edit question" action for "TF" in the question bank And I set the field "Correct answer" to "False" And I press "id_submitbutton" And I navigate to "Results" in current page administration And "student3@example.com" row "Q. 1/100.00" column of "attempts" table should contain "100.00" And "Correct" "icon" should be visible And I press "Regrade attempts..." And I click on "Regrade now" "button" in the "Regrade" "dialogue" And I should see "Finished regrading (1/1)" And I should see "Regrade completed" And I press "Continue" Then "student3@example.com" row "Q. 1/100.00" column of "attempts" table should contain "100.00/0.00" And "Incorrect" "icon" should be visible PK9B\+R R :report/overview/tests/behat/regrading_not_possible.featurenu[@mod @mod_quiz @quiz @quiz_overview @javascript Feature: Quiz regrade when not possible In order avoid errors As a teacher I need the system to prevent impossible regrade scenarios Background: Given the following "users" exist: | username | firstname | lastname | | teacher | Mark | Allwright | | student | Student | One | 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 "activities" exist: | activity | name | course | idnumber | | quiz | Quiz for testing regrading | C1 | quiz1 | And the following "question categories" exist: | contextlevel | reference | name | | Activity module | quiz1 | Test questions | And the following "questions" exist: | questioncategory | qtype | template | name | | Test questions | multichoice | one_of_four | MC | And quiz "Quiz for testing regrading" contains the following questions: | question | page | maxmark | | MC | 1 | 10.0 | And user "student" has attempted "Quiz for testing regrading" with responses: | slot | response | | 1 | B | Scenario: Try a regrade after the question has been edited to have a different number of choices # Edit the question so that V2 has the fourth choice removed. Given I am on the "MC" "core_question > edit" page logged in as teacher And I set the following fields to these values: | Choice 4 | | | id_feedback_3 | | And I press "id_submitbutton" # Try a regrade, and verify what happened is reported. When I am on the "Quiz for testing regrading" "mod_quiz > grades report" page And I press "Regrade attempts..." And I click on "Regrade now" "button" in the "Regrade" "dialogue" Then I should see "Quiz for testing regrading" And I should see "The following questions could not be regraded in attempt 1 by Student One" And I should see "Slot 1: The number of choices in the question has changed." And I should see "Finished regrading (1/1)" And I should see "Regrade completed" And I press "Continue" # These next tests just serve to check we got back to the report. And I should see "Quiz for testing regrading" And I should see "Overall number of students achieving grade ranges" PK9B\ 2report/overview/tests/behat/attempt_states.featurenu[@mod @mod_quiz @quiz @quiz_overview Feature: View attempt states In order to see how students are progressing through the quiz As a teacher I need to see different attempt states on the overview report Background: Given the following "users" exist: | username | firstname | lastname | email | | teacher1 | Teacher | 1 | teacher1@example.com | | student1 | Student | 1 | student1@example.com | | student2 | Student | 2 | student2@example.com | | student3 | Student | 3 | student3@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 | | student2 | C1 | student | | student3 | C1 | student | And the following "question categories" exist: | contextlevel | reference | name | | Course | C1 | Test questions | And the following "activities" exist: | activity | name | intro | course | idnumber | | quiz | Quiz 1 | Quiz 1 description | C1 | quiz1 | And the following "questions" exist: | questioncategory | qtype | name | questiontext | | Test questions | description | Intro | Welcome to this quiz | | Test questions | truefalse | TF1 | First question | | Test questions | truefalse | TF2 | Second question | And quiz "Quiz 1" contains the following questions: | question | page | maxmark | | Intro | 1 | | | TF1 | 1 | | | TF2 | 1 | 3.0 | Scenario: View attempts in different states Given quiz "Quiz 1" has pre-created attempts And user "student1" has started an attempt at quiz "Quiz 1" And user "student2" has attempted "Quiz 1" with responses: | slot | response | | 2 | True | | 3 | False | When I am on the "Quiz 1" "mod_quiz > Grades report" page logged in as "teacher 1" Then the following should exist in the "attempts" table: | Email address | Status | Started | Completed | Grade/100.00 | | student1@example.com | In progress | ## now ##%d %B %Y %I:%M %p## | - | - | | student2@example.com | Finished | ## now ##%d %B %Y %I:%M %p## | ## now ##%d %B %Y %I:%M %p## | 25.00 | | student3@example.com | Not started | - | - | - | PK9B\Jnޭ)report/overview/tests/behat/basic.featurenu[@mod @mod_quiz @quiz @quiz_overview Feature: Basic use of the Grades report In order to easily get an overview of quiz attempts As a teacher I need to use the Grades report Background: Given the "multilang" filter is "on" And the "multilang" filter applies to "content and headings" And the following "custom profile fields" exist: | datatype | shortname | name | | text | fruit | Fruit | And the following "users" exist: | username | firstname | lastname | email | idnumber | profile_field_fruit | | teacher1 | T1 | Teacher1 | teacher1@example.com | T1000 | | | student1 | S1 | Student1 | student1@example.com | S1000 | Apple | | student2 | S2 | Student2 | student2@example.com | S2000 | Banana | | student3 | S3 | Student3 | student3@example.com | S3000 | Pear | 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 | | student2 | C1 | student | | student3 | C1 | student | And the following "groups" exist: | course | idnumber | name | | C1 | G1 | EnglishSpanish | | C1 | G2 | Group 2 | And the following "group members" exist: | group | user | | G1 | student1 | | G1 | student2 | | G2 | student3 | And the following "question categories" exist: | contextlevel | reference | name | | Course | C1 | Test questions | And the following "activities" exist: | activity | name | intro | course | idnumber | groupmode | | quiz | Quiz 1 | Quiz 1 description | C1 | quiz1 | 2 | And the following "questions" exist: | questioncategory | qtype | name | questiontext | | Test questions | description | Intro | Welcome to this quiz | | Test questions | truefalse | TF1 | First question | | Test questions | truefalse | TF2 | Second question | And quiz "Quiz 1" contains the following questions: | question | page | maxmark | displaynumber | | Intro | 1 | | | | TF1 | 1 | | | | TF2 | 1 | 3.0 | 2a | And user "student1" has attempted "Quiz 1" with responses: | slot | response | | 2 | True | | 3 | False | And user "student2" has attempted "Quiz 1" with responses: | slot | response | | 2 | True | | 3 | True | @javascript Scenario: Using the Grades report # Basic check of the Grades report When I am on the "Quiz 1" "quiz activity" page logged in as teacher1 And I navigate to "Results" in current page administration Then I should see "Attempts: 2" # Verify that the right columns are visible And I should see "Q. 1" And I should see "Q. 2a" And I should not see "Q. 3" # Check student1's grade And I should see "25.00" in the "S1 Student1" "table_row" # And student2's grade And I should see "100.00" in the "S2 Student2" "table_row" # Check changing the form parameters And I set the field "Attempts from" to "enrolled users who do not have a quiz attempt" And I press "Show report" # Note: teachers should not appear in the report. # Check student3's grade And I should see "-" in the "S3 Student3" "table_row" And I set the field "Attempts from" to "enrolled users who have, or do not have, a quiz attempt" And I press "Show report" # Check student1's grade And I should see "25.00" in the "S1 Student1" "table_row" # Check student2's grade And I should see "100.00" in the "S2 Student2" "table_row" # Check student3's grade And I should see "-" in the "S3 Student3" "table_row" And I set the field "Attempts from" to "all users who have a quiz attempt" And I press "Show report" # Check student1's grade And I should see "25.00" in the "S1 Student1" "table_row" # Check student2's grade And I should see "100.00" in the "S2 Student2" "table_row" # Verify groups are displayed correctly. And I set the field "Visible groups" to "English" And I should see "Number of students in group 'English' achieving grade ranges" @javascript Scenario: View custom user profile fields in the grades report Given the following config values are set as admin: | showuseridentity | email,profile_field_fruit | And I am on the "Quiz 1" "quiz activity" page logged in as teacher1 And I navigate to "Results" in current page administration Then I should see "Apple" in the "S1 Student1" "table_row" And I should see "Banana" in the "S2 Student2" "table_row" PK9B\Qع? ? 8report/overview/tests/behat/multiple_grade_items.featurenu[@mod @mod_quiz @quiz @quiz_overview Feature: Grades report for a quiz with multiple grade items In to get an overview of quiz attempt grade As a teacher I need the Grades report to show all grade items Background: Given the following "users" exist: | username | firstname | lastname | | student | Lorna | Lott | | teacher | Mark | Allwright | And the following "courses" exist: | fullname | shortname | | Course 1 | C1 | And the following "course enrolments" exist: | user | course | role | | student | C1 | student | | teacher | C1 | teacher | And the following "question categories" exist: | contextlevel | reference | name | | Course | C1 | Test questions | And the following "activities" exist: | activity | name | course | | quiz | Test quiz | C1 | @javascript Scenario: Quiz grades report with multiple grade items Given the following "questions" exist: | questioncategory | qtype | name | questiontext | | Test questions | truefalse | Reading | Can you read this? | | Test questions | truefalse | Listening | Can you hear this? | And the following "mod_quiz > grade items" exist: | quiz | name | | Test quiz | Reading | | Test quiz | Listening | And quiz "Test quiz" contains the following questions: | question | page | grade item | | Reading | 1 | Reading | | Listening | 1 | Listening | And user "student" has attempted "Test quiz" with responses: | slot | response | | 1 | True | | 2 | False | When I am on the "Test quiz" "mod_quiz > Grades report" page logged in as teacher Then "Lorna LottReview attempt" row "Grade/100.00" column of "attempts" table should contain "50.00" And "Lorna LottReview attempt" row "Q. 1/50.00" column of "attempts" table should contain "0.00" And "Lorna LottReview attempt" row "Q. 2/50.00" column of "attempts" table should contain "0.00" And "Lorna LottReview attempt" row "Reading/1.00" column of "attempts" table should contain "1.00" And "Lorna LottReview attempt" row "Listening/1.00" column of "attempts" table should contain "0.00" # Main thing to check here is that sorting does not give a fatal error And I click on "//a[@aria-label='Sort by Reading/1.00 ascending']" "xpath_element" in the "attempts" "table" And "Lorna LottReview attempt" row "Listening/1.00" column of "attempts" table should contain "0.00" PK9B\hzMM%report/overview/tests/report_test.phpnu[. namespace quiz_overview; use core_question\local\bank\question_version_status; use mod_quiz\external\submit_question_version; use mod_quiz\quiz_attempt; use question_engine; use mod_quiz\quiz_settings; use mod_quiz\local\reports\attempts_report; use quiz_overview_options; use quiz_overview_report; use quiz_overview_table; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->dirroot . '/mod/quiz/locallib.php'); require_once($CFG->dirroot . '/mod/quiz/report/reportlib.php'); require_once($CFG->dirroot . '/mod/quiz/report/overview/report.php'); require_once($CFG->dirroot . '/mod/quiz/report/overview/overview_form.php'); require_once($CFG->dirroot . '/mod/quiz/report/overview/tests/helpers.php'); require_once($CFG->dirroot . '/mod/quiz/tests/quiz_question_helper_test_trait.php'); /** * Tests for the quiz overview report. * * @package quiz_overview * @copyright 2014 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class report_test extends \advanced_testcase { use \quiz_question_helper_test_trait; /** * Data provider for test_report_sql. * * @return array the data for the test sub-cases. */ public static function report_sql_cases(): array { return [[null], ['csv']]; // Only need to test on or off, not all download types. } /** * Test how the report queries the database. * * @param string|null $isdownloading a download type, or null. * @dataProvider report_sql_cases */ public function test_report_sql(?string $isdownloading): void { global $DB; $this->resetAfterTest(); // Create a course and a quiz. $generator = $this->getDataGenerator(); $course = $generator->create_course(); $quizgenerator = $generator->get_plugin_generator('mod_quiz'); $quiz = $quizgenerator->create_instance(['course' => $course->id, 'grademethod' => QUIZ_GRADEHIGHEST, 'grade' => 100.0, 'sumgrades' => 10.0, 'attempts' => 10]); // Add one question. /** @var core_question_generator $questiongenerator */ $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); $cat = $questiongenerator->create_question_category(); $q = $questiongenerator->create_question('essay', 'plain', ['category' => $cat->id]); quiz_add_quiz_question($q->id, $quiz, 0 , 10); // Create some students and enrol them in the course. $student1 = $generator->create_user(); $student2 = $generator->create_user(); $student3 = $generator->create_user(); $generator->enrol_user($student1->id, $course->id); $generator->enrol_user($student2->id, $course->id); $generator->enrol_user($student3->id, $course->id); // This line is not really necessary for the test asserts below, // but what it does is add an extra user row returned by // get_enrolled_with_capabilities_join because of a second enrolment. // The extra row returned used to make $table->query_db complain // about duplicate records. So this is really a test that an extra // student enrolment does not cause duplicate records in this query. $generator->enrol_user($student2->id, $course->id, null, 'self'); // Also create a user who should not appear in the reports, // because they have a role with neither 'mod/quiz:attempt' // nor 'mod/quiz:reviewmyattempts'. $tutor = $generator->create_user(); $generator->enrol_user($tutor->id, $course->id, 'teacher'); // The test data. $timestamp = 1234567890; $attempts = [ [$quiz, $student1, 1, 0.0, quiz_attempt::FINISHED], [$quiz, $student1, 2, 5.0, quiz_attempt::FINISHED], [$quiz, $student1, 3, 8.0, quiz_attempt::FINISHED], [$quiz, $student1, 4, null, quiz_attempt::ABANDONED], [$quiz, $student1, 5, null, quiz_attempt::SUBMITTED], [$quiz, $student1, 6, null, quiz_attempt::IN_PROGRESS], [$quiz, $student2, 1, null, quiz_attempt::ABANDONED], [$quiz, $student2, 2, null, quiz_attempt::ABANDONED], [$quiz, $student2, 3, 7.0, quiz_attempt::FINISHED], [$quiz, $student2, 4, null, quiz_attempt::ABANDONED], [$quiz, $student2, 5, null, quiz_attempt::ABANDONED], ]; // Load it in to quiz attempts table. foreach ($attempts as $attemptdata) { list($quiz, $student, $attemptnumber, $sumgrades, $state) = $attemptdata; $timestart = $timestamp + $attemptnumber * 3600; $quizobj = quiz_settings::create($quiz->id, $student->id); $quba = question_engine::make_questions_usage_by_activity('mod_quiz', $quizobj->get_context()); $quba->set_preferred_behaviour($quizobj->get_quiz()->preferredbehaviour); // Create the new attempt and initialize the question sessions. $attempt = quiz_create_attempt($quizobj, $attemptnumber, null, $timestart, false, $student->id); $attempt = quiz_start_new_attempt($quizobj, $quba, $attempt, $attemptnumber, $timestamp); $attempt = quiz_attempt_save_started($quizobj, $quba, $attempt); // Process some responses from the student. $attemptobj = quiz_attempt::create($attempt->id); switch ($state) { case quiz_attempt::ABANDONED: $attemptobj->process_abandon($timestart + 300, false); break; case quiz_attempt::IN_PROGRESS: // Do nothing. break; case quiz_attempt::SUBMITTED: // Save answers but do not grade attempt. $attemptobj->process_submitted_actions( $timestart + 300, false, [ 1 => ['answer' => 'My essay by ' . $student->firstname, 'answerformat' => FORMAT_PLAIN], ] ); $attemptobj->process_submit($timestart + 600, false); break; case quiz_attempt::FINISHED: // Save answer and finish attempt. $attemptobj->process_submitted_actions( $timestart + 300, false, [ 1 => ['answer' => 'My essay by ' . $student->firstname, 'answerformat' => FORMAT_PLAIN], ] ); $attemptobj->process_submit($timestart + 600, false); $attemptobj->process_grade_submission($timestart + 600); // Manually grade it. $quba = $attemptobj->get_question_usage(); $quba->get_question_attempt(1)->manual_grade( 'Comment', $sumgrades, FORMAT_HTML, $timestart + 1200); question_engine::save_questions_usage_by_activity($quba); $update = new \stdClass(); $update->id = $attemptobj->get_attemptid(); $update->timemodified = $timestart + 1200; $update->sumgrades = $quba->get_total_mark(); $DB->update_record('quiz_attempts', $update); $attemptobj->get_quizobj()->get_grade_calculator()->recompute_final_grade($student->id); break; } } // Actually getting the SQL to run is quite hard. Do a minimal set up of // some objects. $context = \context_module::instance($quiz->cmid); $cm = get_coursemodule_from_id('quiz', $quiz->cmid); $qmsubselect = quiz_report_qm_filter_select($quiz); $studentsjoins = get_enrolled_with_capabilities_join($context, '', ['mod/quiz:attempt', 'mod/quiz:reviewmyattempts']); $empty = new \core\dml\sql_join(); // Set the options. $reportoptions = new quiz_overview_options('overview', $quiz, $cm, null); $reportoptions->attempts = attempts_report::ENROLLED_ALL; $reportoptions->onlygraded = true; $reportoptions->states = [quiz_attempt::IN_PROGRESS, quiz_attempt::OVERDUE, quiz_attempt::FINISHED]; // Now do a minimal set-up of the table class. $q->slot = 1; $q->maxmark = 10; $table = new quiz_overview_table($quiz, $context, $qmsubselect, $reportoptions, $empty, $studentsjoins, [1 => $q], null); $table->download = $isdownloading; // Cannot call the is_downloading API, because it gives errors. $table->define_columns(['fullname']); $table->sortable(true, 'uniqueid'); $table->define_baseurl(new \moodle_url('/mod/quiz/report.php')); $table->setup(); // Run the query. $table->setup_sql_queries($studentsjoins); $table->query_db(30, false); // Should be 4 rows, matching count($table->rawdata) tested below. // The count is only done if not downloading. if (!$isdownloading) { $this->assertEquals(4, $table->totalrows); } // Verify what was returned: Student 1's best and in progress attempts. // Student 2's finshed attempt, and Student 3 with no attempt. // The array key is {student id}#{attempt number}. $this->assertEquals(4, count($table->rawdata)); $this->assertArrayHasKey($student1->id . '#3', $table->rawdata); $this->assertEquals(1, $table->rawdata[$student1->id . '#3']->gradedattempt); $this->assertArrayHasKey($student1->id . '#3', $table->rawdata); $this->assertEquals(0, $table->rawdata[$student1->id . '#6']->gradedattempt); $this->assertArrayHasKey($student2->id . '#3', $table->rawdata); $this->assertEquals(1, $table->rawdata[$student2->id . '#3']->gradedattempt); $this->assertArrayHasKey($student3->id . '#0', $table->rawdata); $this->assertEquals(0, $table->rawdata[$student3->id . '#0']->gradedattempt); // Check the calculation of averages. $averagerow = $table->compute_average_row('overallaverage', $studentsjoins); $this->assertStringContainsString('75.00', $averagerow['sumgrades']); $this->assertStringContainsString('75.00', $averagerow['qsgrade1']); if (!$isdownloading) { $this->assertStringContainsString('(2)', $averagerow['sumgrades']); $this->assertStringContainsString('(2)', $averagerow['qsgrade1']); } // Ensure that filtering by initial does not break it. // This involves setting a private properly of the base class, which is // only really possible using reflection :-(. $reflectionobject = new \ReflectionObject($table); while ($parent = $reflectionobject->getParentClass()) { $reflectionobject = $parent; } $prefsproperty = $reflectionobject->getProperty('prefs'); $prefs = $prefsproperty->getValue($table); $prefs['i_first'] = 'A'; $prefsproperty->setValue($table, $prefs); list($fields, $from, $where, $params) = $table->base_sql($studentsjoins); $table->set_count_sql("SELECT COUNT(1) FROM (SELECT $fields FROM $from WHERE $where) temp WHERE 1 = 1", $params); $table->set_sql($fields, $from, $where, $params); $table->query_db(30, false); // Just verify that this does not cause a fatal error. } /** * Bands provider. * @return array */ public static function get_bands_count_and_width_provider(): array { return [ [10, [20, .5]], [20, [20, 1]], [30, [15, 2]], // TODO MDL-55068 Handle bands better when grade is 50. // [50, [10, 5]], [100, [20, 5]], [200, [20, 10]], ]; } /** * Test bands. * * @dataProvider get_bands_count_and_width_provider * @param int $grade grade * @param array $expected */ public function test_get_bands_count_and_width(int $grade, array $expected): void { $this->resetAfterTest(); $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz'); $quiz = $quizgenerator->create_instance(['course' => SITEID, 'grade' => $grade]); $this->assertEquals($expected, quiz_overview_report::get_bands_count_and_width($quiz)); } /** * Test delete_selected_attempts function. */ public function test_delete_selected_attempts(): void { $this->resetAfterTest(); $timestamp = 1234567890; $timestart = $timestamp + 3600; // Create a course and a quiz. $generator = $this->getDataGenerator(); $course = $generator->create_course(); $quizgenerator = $generator->get_plugin_generator('mod_quiz'); $quiz = $quizgenerator->create_instance([ 'course' => $course->id, 'grademethod' => QUIZ_GRADEHIGHEST, 'grade' => 100.0, 'sumgrades' => 10.0, 'attempts' => 10 ]); // Add one question. /** @var core_question_generator $questiongenerator */ $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); $cat = $questiongenerator->create_question_category(); $q = $questiongenerator->create_question('essay', 'plain', ['category' => $cat->id]); quiz_add_quiz_question($q->id, $quiz, 0 , 10); // Create student and enrol them in the course. // Note: we create two enrolments, to test the problem reported in MDL-67942. $student = $generator->create_user(); $generator->enrol_user($student->id, $course->id); $generator->enrol_user($student->id, $course->id, null, 'self'); $context = \context_module::instance($quiz->cmid); $cm = get_coursemodule_from_id('quiz', $quiz->cmid); $allowedjoins = get_enrolled_with_capabilities_join($context, '', ['mod/quiz:attempt', 'mod/quiz:reviewmyattempts']); $quizattemptsreport = new \testable_quiz_attempts_report(); // Create the new attempt and initialize the question sessions. $quizobj = quiz_settings::create($quiz->id, $student->id); $quba = question_engine::make_questions_usage_by_activity('mod_quiz', $quizobj->get_context()); $quba->set_preferred_behaviour($quizobj->get_quiz()->preferredbehaviour); $attempt = quiz_create_attempt($quizobj, 1, null, $timestart, false, $student->id); $attempt = quiz_start_new_attempt($quizobj, $quba, $attempt, 1, $timestamp); $attempt = quiz_attempt_save_started($quizobj, $quba, $attempt); // Delete the student's attempt. $quizattemptsreport->delete_selected_attempts($quiz, $cm, [$attempt->id], $allowedjoins); } /** * Test question regrade for selected versions. * * @covers ::regrade_question */ public function test_regrade_question(): void { global $DB; $this->resetAfterTest(); $this->setAdminUser(); $course = $this->getDataGenerator()->create_course(); $quiz = $this->create_test_quiz($course); $cm = get_fast_modinfo($course->id)->get_cm($quiz->cmid); $context = \context_module::instance($quiz->cmid); /** @var core_question_generator $questiongenerator */ $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); // Create a couple of questions. $cat = $questiongenerator->create_question_category(['contextid' => $context->id]); $q = $questiongenerator->create_question('shortanswer', null, ['category' => $cat->id, 'name' => 'Toad scores 0.8']); // Create a version, the last one draft. // Sadly, update_question is a bit dodgy, so it can't handle updating the answer score. $q2 = $questiongenerator->update_question($q, null, ['name' => 'Toad now scores 1.0']); $toadanswer = $DB->get_record_select('question_answers', 'question = ? AND ' . $DB->sql_compare_text('answer') . ' = ?', [$q2->id, 'toad'], '*', MUST_EXIST); $DB->set_field('question_answers', 'fraction', 1, ['id' => $toadanswer->id]); // Add the question to the quiz. quiz_add_quiz_question($q2->id, $quiz, 0, 10); // Attempt the quiz, submitting response 'toad'. $quizobj = quiz_settings::create($quiz->id); $attempt = quiz_prepare_and_start_new_attempt($quizobj, 1, null); $attemptobj = quiz_attempt::create($attempt->id); $attemptobj->process_submitted_actions(time(), false, [1 => ['answer' => 'toad']]); $attemptobj->process_submit(time(), false); $attemptobj->process_grade_submission(time()); // We should be using 'always latest' version, which is currently v2, so should be right. $this->assertEquals(10, $attemptobj->get_question_usage()->get_total_mark()); // Now change the quiz to use fixed version 1. $slot = $quizobj->get_question($q2->id); submit_question_version::execute($slot->slotid, 1); // Regrade. $report = new quiz_overview_report(); $report->init('overview', 'quiz_overview_settings_form', $quiz, $cm, $course); $report->regrade_attempt($attempt); // The mark should now be 8. $attemptobj = quiz_attempt::create($attempt->id); $this->assertEquals(8, $attemptobj->get_question_usage()->get_total_mark()); // Now add two more versions, the second of which is draft. $q3 = $questiongenerator->update_question($q, null, ['name' => 'Toad now scores 0.5']); $toadanswer = $DB->get_record_select('question_answers', 'question = ? AND ' . $DB->sql_compare_text('answer') . ' = ?', [$q3->id, 'toad'], '*', MUST_EXIST); $DB->set_field('question_answers', 'fraction', 0.5, ['id' => $toadanswer->id]); $q4 = $questiongenerator->update_question($q, null, ['name' => 'Toad now scores 0.3', 'status' => question_version_status::QUESTION_STATUS_DRAFT]); $toadanswer = $DB->get_record_select('question_answers', 'question = ? AND ' . $DB->sql_compare_text('answer') . ' = ?', [$q4->id, 'toad'], '*', MUST_EXIST); $DB->set_field('question_answers', 'fraction', 0.3, ['id' => $toadanswer->id]); // Now change the quiz back to always latest and regrade again. submit_question_version::execute($slot->slotid, 0); $report->regrade_attempt($attempt); // Score should now be 5, because v3 is the latest non-draft version. $attemptobj = quiz_attempt::create($attempt->id); $this->assertEquals(5, $attemptobj->get_question_usage()->get_total_mark()); } } PK9B\(|PP!report/overview/overview_form.phpnu[. use mod_quiz\local\reports\attempts_report; use mod_quiz\local\reports\attempts_report_options_form; /** * Quiz overview report settings form. * * @copyright 2008 Jamie Pratt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class quiz_overview_settings_form extends attempts_report_options_form { protected function other_attempt_fields(MoodleQuickForm $mform) { if (has_capability('mod/quiz:regrade', $this->_customdata['context'])) { $mform->addElement('advcheckbox', 'onlyregraded', get_string('reportshowonly', 'quiz'), get_string('optonlyregradedattempts', 'quiz_overview')); $mform->disabledIf('onlyregraded', 'attempts', 'eq', attempts_report::ENROLLED_WITHOUT); } } protected function other_preference_fields(MoodleQuickForm $mform) { if (quiz_has_grades($this->_customdata['quiz'])) { $mform->addElement('selectyesno', 'slotmarks', get_string('showdetailedmarks', 'quiz_overview')); } else { $mform->addElement('hidden', 'slotmarks', 0); $mform->setType('slotmarks', PARAM_INT); } } } PK9B\o|44report/attemptsreport_form.phpnu[. /** * File only retained to prevent fatal errors in code that tries to require/include this. * * @todo MDL-76612 delete this file as part of Moodle 4.6 development. * @deprecated This file is no longer required in Moodle 4.2+. */ defined('MOODLE_INTERNAL') || die(); debugging('This file is no longer required in Moodle 4.2+. Please do not include/require it.', DEBUG_DEVELOPER); PK9B\^ Zreport/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 quiz report plugins. Overview of this plugin type at http://docs.moodle.org/dev/Quiz_reports === 4.4 === * The function quiz_grading_report::format_count_for_table now requires a string identifier and a component identifier so that it is possible to address strings outside of quiz/report/grading/lang/en/quiz_grading.php. * The function quiz_grading_renderer::render_grade_link now requires a string identifier and a component identifier so that it is possible to address strings outside of quiz/report/grading/lang/en/quiz_grading.php. === 4.2 === * There was an ill-advised, never documented, API change in quiz_report_get_significant_questions in Moodle 4.0. The API has now been reverted to how it was before 4.0. Hopefully this will not cause anyone a problem. (The API revert did not require any changes in any automated tests or standard quiz reports.) * The quiz has a lot of old classes in lib.php files. These have now been moved into the classes folder, and so are now in namespaces. Because of Moodle's class renaming support, your code should continue working, but output deprecated warnings, so you probably want to update. This should mostly be doable by adding use statements, and a search/replace. - quiz_default_report => mod_quiz\local\reports\report_base - quiz_attempts_report => mod_quiz\local\reports\attempts_report - mod_quiz_attempts_report_form => mod_quiz\local\reports\attempts_report_options_form - mod_quiz_attempts_report_options => mod_quiz\local\reports\attempts_report_options - quiz_attempts_report_table => mod_quiz\local\reports\attempts_report_table As part of the clean-up, the following files are no longer required, and if you try to include them, you will get a debugging notices telling you not to: - mod/quiz/report/attemptsreport.php - mod/quiz/report/attemptsreport_form.php - mod/quiz/report/attemptsreport_options.php - mod/quiz/report/attemptsreport_table.php - mod/quiz/report/default.php * Final deprecation (complete removal) of the following functions which were deprecated long ago: - attempts_report::load_relevant_students - deprecated in 3.2 - quiz_statistics_graph_get_new_colour - deprecated since 3.2 - The file mod/quiz/report/overview/overviewgraph.php - deprecated since 3.2 - The file mod/quiz/report/statistics/statistics_graph.php - deprecated since 3.2 === 3.9 === * Quiz report plugins defining capabilities used to require an extra string like $string['statistics:componentname'] = 'Quiz statistics report'; in addition to $string['pluginname'] = 'Statistics'; This is no longer required. === 3.8 === * New quiz_attempts_report_table method: \quiz_attempts_report_table::checkbox_col_header() This generates a column header containing a checkbox that toggles the checked state of all the checkboxes corresponding to the entries listed on a given quiz report table. It requires the name of the checkbox column as a parameter in order to disable sorting on the checkbox column. === 3.2 === * A code refactoring based on new sql functions in MDL-31243 and removing get_users_by_capability from the quiz reports in MDL-27072. The class quiz_attempts_report is now initialised to return \core\dml\sql_join (joins, wheres, params) rather than arrays of userids. This allows the use of joins in quiz report queries and is very important when there are larger numbers of enrolled users. The signature of many quiz report methods now includes '$studentsjoins', rather than '$students' and similar for '$groupstudentsjoins', '$allowedjoins' and '$usersjoins'. For clear examples of the use of these changes please see attemptsreport_table.php base_sql() or almost any function in overview/report.php. The protected function quiz_attempts_report::load_relevant_students is deprecated, please use quiz_attempts_report::get_students_joins() instead. === 2.6 === * Improving the display page and heading levels to have a proper nesting. We are reducing the amount of white spaces for the top report by making 'separate group' options and attempts to be displayed inline. In grading report, we are changing 'no questions' from using heading to notification message. (MDL-41615) === 2.3 === * Support for the old way of doing cron in a separate cron.php file has been removed. Instead, you need a lib.php file inside the plugin with a cron function called quiz_myreportname_cron(). The statistics report is an example of how it should be done. * There was a big refactor of the quiz reports, in issues MDL-32300, MDL-32322 and MDL-3030. It is difficult to explain the changes. Probably the best way to understand what happened is to look at git log mod/quiz/report/overview git log mod/quiz/report/responses and so on. Here are some notes on a few of the changes: The class quiz_attempt_report was renamed to quiz_attempts_report (with an extra s). Some globally defined constants with the prefix QUIZ_REPORT_ATTEMPTS_ moved into the quiz_attempts_report class. Specifically quiz_attempts_report::ALL_WITH replaces QUIZ_REPORT_ATTEMPTS_ALL quiz_attempts_report::ENROLLED_ALL replaces QUIZ_REPORT_ATTEMPTS_ALL_STUDENTS quiz_attempts_report::ENROLLED_WITH replaces QUIZ_REPORT_ATTEMPTS_STUDENTS_WITH quiz_attempts_report::ENROLLED_WITHOUT replaces QUIZ_REPORT_ATTEMPTS_STUDENTS_WITH_NO Your if you have a table class, it needs to be renamed like quiz_report_myreportname_table -> quiz_myreportname_table. That is, all the class names in your plugin should start with the frankenstyle plugin name quiz_myreportname. === 2.2 === * Plugins should be converted to implement cron in the standard way. In lib.php, define a function quiz_myreportname_cron() {}; This replaces the old way of having a separate cron.php file. Also, the cron frequency should be defined in version.php, not in the quiz_reports table. === earlier versions === * ... API changes were not documented properly. Sorry. (There weren't many!) PK9B\o|44report/attemptsreport_table.phpnu[. /** * File only retained to prevent fatal errors in code that tries to require/include this. * * @todo MDL-76612 delete this file as part of Moodle 4.6 development. * @deprecated This file is no longer required in Moodle 4.2+. */ defined('MOODLE_INTERNAL') || die(); debugging('This file is no longer required in Moodle 4.2+. Please do not include/require it.', DEBUG_DEVELOPER); PK9B\**1report/responses/first_or_all_responses_table.phpnu[. /** * This file defines the quiz responses table for showing first or all tries at a question. * * @package quiz_responses * @copyright 2014 The Open University * @author Jamie Pratt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ use mod_quiz\quiz_attempt; defined('MOODLE_INTERNAL') || die(); /** * This is a table subclass for displaying the quiz responses report, showing first or all tries. * * @package quiz_responses * @copyright 2014 The Open University * @author Jamie Pratt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class quiz_first_or_all_responses_table extends quiz_last_responses_table { /** * The full question usage object for each try shown in report. * * @var question_usage_by_activity[] */ protected $questionusagesbyactivity; protected function field_from_extra_data($tablerow, $slot, $field) { $questionattempt = $this->get_question_attempt($tablerow->usageid, $slot); switch($field) { case 'questionsummary' : return $questionattempt->get_question_summary(); case 'responsesummary' : return $this->get_summary_after_try($tablerow, $slot); case 'rightanswer' : return $questionattempt->get_right_answer_summary(); default : throw new coding_exception('Unknown question attempt field.'); } } protected function load_extra_data() { if (count($this->rawdata) === 0) { return; } $qubaids = $this->get_qubaids_condition(); $dm = new question_engine_data_mapper(); $this->questionusagesbyactivity = $dm->load_questions_usages_by_activity($qubaids); // Insert an extra field in attempt data and extra rows where necessary. $newrawdata = []; foreach ($this->rawdata as $attempt) { if (!isset($this->questionusagesbyactivity[$attempt->usageid])) { // This is a user without attempts. $attempt->try = 0; $attempt->lasttryforallparts = true; $newrawdata[] = $attempt; continue; } // We have an attempt, which may require several rows. $maxtriesinanyslot = 1; foreach ($this->questionusagesbyactivity[$attempt->usageid]->get_slots() as $slot) { $tries = $this->get_no_of_tries($attempt, $slot); $maxtriesinanyslot = max($maxtriesinanyslot, $tries); } for ($try = 1; $try <= $maxtriesinanyslot; $try++) { $newtablerow = clone($attempt); $newtablerow->lasttryforallparts = ($try == $maxtriesinanyslot); if ($try !== $maxtriesinanyslot) { $newtablerow->state = quiz_attempt::IN_PROGRESS; } $newtablerow->try = $try; $newrawdata[] = $newtablerow; if ($this->options->whichtries == question_attempt::FIRST_TRY) { break; } } } $this->rawdata = $newrawdata; } /** * Return the question attempt object. * * @param int $questionusagesid * @param int $slot * @return question_attempt */ protected function get_question_attempt($questionusagesid, $slot) { return $this->questionusagesbyactivity[$questionusagesid]->get_question_attempt($slot); } /** * Find the state for $slot given after this try. * * @param stdClass $tablerow row data * @param int $slot Slot number. * @return question_state The question state after the attempt. */ protected function slot_state($tablerow, $slot) { $qa = $this->get_question_attempt($tablerow->usageid, $slot); $submissionsteps = $qa->get_steps_with_submitted_response_iterator(); $step = $submissionsteps[$tablerow->try]; if ($step === null) { return null; } if ($this->is_last_try($tablerow, $slot, $tablerow->try)) { // If this is the last try then the step with the try data does not contain the correct state. We need to // use the last step's state, after the attempt has been finished. return $qa->get_state(); } return $step->get_state(); } /** * Get the summary of the response after the try. * * @param stdClass $tablerow row data * @param int $slot Slot number. * @return string summary for the question after this try. */ public function get_summary_after_try($tablerow, $slot) { $qa = $this->get_question_attempt($tablerow->usageid, $slot); if (!($qa->get_question(false) instanceof question_manually_gradable)) { // No responses, and we cannot call summarise_response below. return null; } $submissionsteps = $qa->get_steps_with_submitted_response_iterator(); $step = $submissionsteps[$tablerow->try]; if ($step === null) { return null; } $qtdata = $step->get_qt_data(); return $qa->get_question()->summarise_response($qtdata); } /** * Has this question usage been flagged? * * @param int $questionusageid Question usage id. * @param int $slot Slot number * @return bool Has it been flagged? */ protected function is_flagged($questionusageid, $slot) { return $this->get_question_attempt($questionusageid, $slot)->is_flagged(); } /** * The grade for this slot after this try. * * @param stdClass $tablerow attempt data from db. * @param int $slot Slot number. * @return float The fraction. */ protected function slot_fraction($tablerow, $slot) { $qa = $this->get_question_attempt($tablerow->usageid, $slot); $submissionsteps = $qa->get_steps_with_submitted_response_iterator(); $step = $submissionsteps[$tablerow->try]; if ($step === null) { return null; } if ($this->is_last_try($tablerow, $slot, $tablerow->try)) { // If this is the last try then the step with the try data does not contain the correct fraction. We need to // use the last step's fraction, after the attempt has been finished. return $qa->get_fraction(); } return $step->get_fraction(); } /** * Is this the last try in the question attempt? * * @param stdClass $tablerow attempt data from db. * @param int $slot Slot number * @param int $tryno try no * @return bool Is it the last try? */ protected function is_last_try($tablerow, $slot, $tryno) { return $tryno == $this->get_no_of_tries($tablerow, $slot); } /** * How many tries were attempted at this question in this slot, during this usage? * * @param stdClass $tablerow attempt data from db. * @param int $slot Slot number * @return int the number of tries in the question attempt for slot $slot. */ public function get_no_of_tries($tablerow, $slot) { return count($this->get_question_attempt($tablerow->usageid, $slot)->get_steps_with_submitted_response_iterator()); } /** * What is the step no this try was seen in? * * @param int $questionusageid The question usage id. * @param int $slot Slot number * @param int $tryno Try no * @return int the step no or zero if not found */ protected function step_no_for_try($questionusageid, $slot, $tryno) { $qa = $this->get_question_attempt($questionusageid, $slot); return $qa->get_steps_with_submitted_response_iterator()->step_no_for_try($tryno); } public function col_checkbox($tablerow) { if ($tablerow->try != 1) { return ''; } else { return parent::col_checkbox($tablerow); } } /** * Cell value function for email column. This extracts the contents for any cell in the email column from the row data. * * @param stdClass $tablerow Row data. * @return string What to put in the cell for this column, for this row data. */ public function col_email($tablerow) { if ($tablerow->try > 1) { return ''; } else { return $tablerow->email; } } /** * Cell value function for sumgrades column. This extracts the contents for any cell in the sumgrades column from the row data. * * @param stdClass $tablerow Row data. * @return string What to put in the cell for this column, for this row data. */ public function col_sumgrades($tablerow) { if ($tablerow->try == 0) { // We are showing a user without a quiz attempt. return '-'; } else if (!$tablerow->lasttryforallparts) { // There are more rows to come for this quiz attempt, so we will show this later. return ''; } else { // Last row for this attempt. Now is the time to show attempt-related data. return parent::col_sumgrades($tablerow); } } public function col_state($tablerow) { if ($tablerow->try == 0) { // We are showing a user without a quiz attempt. return '-'; } else if (!$tablerow->lasttryforallparts) { // There are more rows to come for this quiz attempt, so we will show this later. return ''; } else { // Last row for this attempt. Now is the time to show attempt-related data. return parent::col_state($tablerow); } } public function get_row_class($tablerow) { if ($this->options->whichtries == question_attempt::ALL_TRIES && $tablerow->lasttryforallparts) { return 'lastrowforattempt'; } else { return ''; } } public function make_review_link($data, $tablerow, $slot) { if ($this->slot_state($tablerow, $slot) === null) { return $data; } else { return parent::make_review_link($data, $tablerow, $slot); } } } PK9B\@Ւreport/responses/db/install.phpnu[. /** * Post-install script for the quiz responses report. * @package quiz_responses * @copyright 2013 Tim Hunt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); /** * Post-install script */ function xmldb_quiz_responses_install() { global $DB; $record = new stdClass(); $record->name = 'responses'; $record->displayorder = '9000'; $DB->insert_record('quiz_reports', $record); } PK9B\c c +report/responses/lang/en/quiz_responses.phpnu[. /** * Strings for component 'quiz_responses', language 'en', branch 'MOODLE_20_STABLE' * * @package quiz_responses * @copyright 1999 onwards Martin Dougiamas {@link http://moodle.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ $string['cannotloadoptions'] = 'Could not load question options'; $string['include'] = 'Include the'; $string['mustselectcols'] = 'You must include something.'; $string['pagesize'] = 'Page size'; $string['pluginname'] = 'Responses'; $string['privacy:preference:qtext'] = 'Whether to show the question text columns.'; $string['privacy:preference:resp'] = 'Whether to show the students\' response columns.'; $string['privacy:preference:right'] = 'Whether to show the correct response columns.'; $string['privacy:preference:which_tries'] = 'Which tries to show responses from.'; $string['privacy:preference:which_tries:first'] = 'The first try at a question during an attempt by a user.'; $string['privacy:preference:which_tries:last'] = 'The last try at a question during an attempt by a user.'; $string['privacy:preference:which_tries:all'] = 'All tries at a question during an attempt by a user.'; $string['questiontext'] = 'question text'; $string['reportresponses'] = 'Responses'; $string['response'] = 'response'; $string['responses'] = 'Responses'; $string['responsesdownload'] = 'Responses download'; $string['responsesfilename'] = 'responses'; $string['responsesoptions'] = 'Responses options'; $string['responsesreport'] = 'Responses report'; $string['responsestitle'] = 'Responses'; $string['responsex'] = 'Response {$a}'; $string['rightanswer'] = 'right answer'; $string['rightanswerx'] = 'Right answer {$a}'; $string['showattempts'] = 'Only show / download attempts'; $string['showthe'] = 'Show the'; $string['summaryofquestiontext'] = 'Summary of the question'; $string['summaryofresponse'] = 'Summary of the response given'; $string['summaryofrightanswer'] = 'Summary of the right answer'; PK9B\9RC report/responses/version.phpnu[. /** * Quiz responses report version information. * * @package quiz_responses * @copyright 2011 Tim Hunt * @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 = 'quiz_responses'; PK9B\6$ #report/responses/responses_form.phpnu[. use mod_quiz\local\reports\attempts_report; use mod_quiz\local\reports\attempts_report_options_form; /** * Quiz responses report settings form. * * @copyright 2008 Jean-Michel Vedrine * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class quiz_responses_settings_form extends attempts_report_options_form { protected function other_preference_fields(MoodleQuickForm $mform) { $mform->addGroup([ $mform->createElement('advcheckbox', 'qtext', '', get_string('questiontext', 'quiz_responses')), $mform->createElement('advcheckbox', 'resp', '', get_string('response', 'quiz_responses')), $mform->createElement('advcheckbox', 'right', '', get_string('rightanswer', 'quiz_responses')), ], 'coloptions', get_string('showthe', 'quiz_responses'), [' '], false); $mform->disabledIf('qtext', 'attempts', 'eq', attempts_report::ENROLLED_WITHOUT); $mform->disabledIf('resp', 'attempts', 'eq', attempts_report::ENROLLED_WITHOUT); $mform->disabledIf('right', 'attempts', 'eq', attempts_report::ENROLLED_WITHOUT); } public function validation($data, $files) { $errors = parent::validation($data, $files); if ($data['attempts'] != attempts_report::ENROLLED_WITHOUT && !( $data['qtext'] || $data['resp'] || $data['right'])) { $errors['coloptions'] = get_string('reportmustselectstate', 'quiz'); } return $errors; } protected function other_attempt_fields(MoodleQuickForm $mform) { parent::other_attempt_fields($mform); if (quiz_allows_multiple_tries($this->_customdata['quiz'])) { $mform->addElement('select', 'whichtries', get_string('whichtries', 'question'), [ question_attempt::FIRST_TRY => get_string('firsttry', 'question'), question_attempt::LAST_TRY => get_string('lasttry', 'question'), question_attempt::ALL_TRIES => get_string('alltries', 'question')] ); $mform->setDefault('whichtries', question_attempt::LAST_TRY); $mform->disabledIf('whichtries', 'attempts', 'eq', attempts_report::ENROLLED_WITHOUT); } } } PK9B\ed;ff-report/responses/classes/privacy/provider.phpnu[. /** * Privacy Subsystem implementation for quiz_responses. * * @package quiz_responses * @copyright 2018 Andrew Nicols * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace quiz_responses\privacy; use core_privacy\local\metadata\collection; use core_privacy\local\request\writer; use core_privacy\local\request\transform; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->dirroot . '/question/engine/questionattempt.php'); /** * Privacy Subsystem for quiz_responses with user preferences. * * @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 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('quiz_report_responses_qtext', 'privacy:preference:qtext'); $collection->add_user_preference('quiz_report_responses_resp', 'privacy:preference:resp'); $collection->add_user_preference('quiz_report_responses_right', 'privacy:preference:right'); $collection->add_user_preference('quiz_report_responses_which_tries', 'privacy:preference:which_tries'); 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) { $preferences = [ 'qtext', 'resp', 'right', ]; foreach ($preferences as $key) { $preference = get_user_preferences("quiz_report_responses_{$key}", null, $userid); if (null !== $preference) { $desc = get_string("privacy:preference:{$key}", 'quiz_responses'); writer::export_user_preference('quiz_responses', $key, transform::yesno($preference), $desc); } } $preference = get_user_preferences("quiz_report_responses_which_tries", null, $userid); if (null !== $preference) { switch($preference) { case \question_attempt::FIRST_TRY: $value = get_string("privacy:preference:which_tries:first", 'quiz_responses'); break; case \question_attempt::LAST_TRY: $value = get_string("privacy:preference:which_tries:last", 'quiz_responses'); break; case \question_attempt::ALL_TRIES: $value = get_string("privacy:preference:which_tries:all", 'quiz_responses'); break; } $desc = get_string("privacy:preference:which_tries", 'quiz_responses'); writer::export_user_preference('quiz_responses', 'which_tries', $value, $desc); } } } PK9B\&report/responses/responses_options.phpnu[. use mod_quiz\local\reports\attempts_report; use mod_quiz\local\reports\attempts_report_options; /** * Class to store the options for a {@link quiz_responses_report}. * * @copyright 2012 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class quiz_responses_options extends attempts_report_options { /** @var bool whether to show the question text columns. */ public $showqtext = false; /** @var bool whether to show the students' response columns. */ public $showresponses = true; /** @var bool whether to show the correct response columns. */ public $showright = false; /** @var bool which try/tries to show responses from. */ public $whichtries = question_attempt::LAST_TRY; protected function get_url_params() { $params = parent::get_url_params(); $params['qtext'] = $this->showqtext; $params['resp'] = $this->showresponses; $params['right'] = $this->showright; if (quiz_allows_multiple_tries($this->quiz)) { $params['whichtries'] = $this->whichtries; } return $params; } public function get_initial_form_data() { $toform = parent::get_initial_form_data(); $toform->qtext = $this->showqtext; $toform->resp = $this->showresponses; $toform->right = $this->showright; if (quiz_allows_multiple_tries($this->quiz)) { $toform->whichtries = $this->whichtries; } return $toform; } public function setup_from_form_data($fromform) { parent::setup_from_form_data($fromform); $this->showqtext = $fromform->qtext; $this->showresponses = $fromform->resp; $this->showright = $fromform->right; if (quiz_allows_multiple_tries($this->quiz)) { $this->whichtries = $fromform->whichtries; } } public function setup_from_params() { parent::setup_from_params(); $this->showqtext = optional_param('qtext', $this->showqtext, PARAM_BOOL); $this->showresponses = optional_param('resp', $this->showresponses, PARAM_BOOL); $this->showright = optional_param('right', $this->showright, PARAM_BOOL); if (quiz_allows_multiple_tries($this->quiz)) { $this->whichtries = optional_param('whichtries', $this->whichtries, PARAM_ALPHA); } } public function setup_from_user_preferences() { parent::setup_from_user_preferences(); $this->showqtext = get_user_preferences('quiz_report_responses_qtext', $this->showqtext); $this->showresponses = get_user_preferences('quiz_report_responses_resp', $this->showresponses); $this->showright = get_user_preferences('quiz_report_responses_right', $this->showright); if (quiz_allows_multiple_tries($this->quiz)) { $this->whichtries = get_user_preferences('quiz_report_responses_which_tries', $this->whichtries); } } public function update_user_preferences() { parent::update_user_preferences(); set_user_preference('quiz_report_responses_qtext', $this->showqtext); set_user_preference('quiz_report_responses_resp', $this->showresponses); set_user_preference('quiz_report_responses_right', $this->showright); if (quiz_allows_multiple_tries($this->quiz)) { set_user_preference('quiz_report_responses_which_tries', $this->whichtries); } } public function resolve_dependencies() { parent::resolve_dependencies(); if (!$this->showqtext && !$this->showresponses && !$this->showright) { // We have to show at least something. $this->showresponses = true; } // We only want to show the checkbox to delete attempts // if the user has permissions and if the report mode is showing attempts. $this->checkboxcolumn = has_capability('mod/quiz:deleteattempts', context_module::instance($this->cm->id)) && ($this->attempts != attempts_report::ENROLLED_WITHOUT); } } PK9B\(__report/responses/report.phpnu[. /** * This file defines the quiz responses report class. * * @package quiz_responses * @copyright 2006 Jean-Michel Vedrine * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ use mod_quiz\local\reports\attempts_report; defined('MOODLE_INTERNAL') || die(); require_once($CFG->dirroot . '/mod/quiz/report/responses/responses_options.php'); require_once($CFG->dirroot . '/mod/quiz/report/responses/responses_form.php'); require_once($CFG->dirroot . '/mod/quiz/report/responses/last_responses_table.php'); require_once($CFG->dirroot . '/mod/quiz/report/responses/first_or_all_responses_table.php'); /** * Quiz report subclass for the responses report. * * This report lists some combination of * * what question each student saw (this makes sense if random questions were used). * * the response they gave, * * and what the right answer is. * * Like the overview report, there are options for showing students with/without * attempts, and for deleting selected attempts. * * @copyright 1999 onwards Martin Dougiamas and others {@link http://moodle.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class quiz_responses_report extends attempts_report { public function display($quiz, $cm, $course) { global $OUTPUT, $DB; list($currentgroup, $studentsjoins, $groupstudentsjoins, $allowedjoins) = $this->init( 'responses', 'quiz_responses_settings_form', $quiz, $cm, $course); $options = new quiz_responses_options('responses', $quiz, $cm, $course); if ($fromform = $this->form->get_data()) { $options->process_settings_from_form($fromform); } else { $options->process_settings_from_params(); } $this->form->set_data($options->get_initial_form_data()); // Load the required questions. $questions = quiz_report_get_significant_questions($quiz); // Prepare for downloading, if applicable. $courseshortname = format_string($course->shortname, true, ['context' => context_course::instance($course->id)]); if ($options->whichtries === question_attempt::LAST_TRY) { $tableclassname = 'quiz_last_responses_table'; } else { $tableclassname = 'quiz_first_or_all_responses_table'; } $table = new $tableclassname($quiz, $this->context, $this->qmsubselect, $options, $groupstudentsjoins, $studentsjoins, $questions, $options->get_url()); $filename = quiz_report_download_filename(get_string('responsesfilename', 'quiz_responses'), $courseshortname, $quiz->name); $table->is_downloading($options->download, $filename, $courseshortname . ' ' . format_string($quiz->name, true)); if ($table->is_downloading()) { raise_memory_limit(MEMORY_EXTRA); } $this->hasgroupstudents = false; if (!empty($groupstudentsjoins->joins)) { $sql = "SELECT DISTINCT u.id FROM {user} u $groupstudentsjoins->joins WHERE $groupstudentsjoins->wheres"; $this->hasgroupstudents = $DB->record_exists_sql($sql, $groupstudentsjoins->params); } $hasstudents = false; if (!empty($studentsjoins->joins)) { $sql = "SELECT DISTINCT u.id FROM {user} u $studentsjoins->joins WHERE $studentsjoins->wheres"; $hasstudents = $DB->record_exists_sql($sql, $studentsjoins->params); } if ($options->attempts == self::ALL_WITH) { // This option is only available to users who can access all groups in // groups mode, so setting allowed to empty (which means all quiz attempts // are accessible, is not a security problem. $allowedjoins = new \core\dml\sql_join(); } $this->process_actions($quiz, $cm, $currentgroup, $groupstudentsjoins, $allowedjoins, $options->get_url()); $hasquestions = quiz_has_questions($quiz->id); // Start output. if (!$table->is_downloading()) { // Only print headers if not asked to download data. $this->print_standard_header_and_messages($cm, $course, $quiz, $options, $currentgroup, $hasquestions, $hasstudents); // Print the display options. $this->form->display(); } $hasstudents = $hasstudents && (!$currentgroup || $this->hasgroupstudents); if ($hasquestions && ($hasstudents || $options->attempts == self::ALL_WITH)) { $table->setup_sql_queries($allowedjoins); if (!$table->is_downloading()) { // Print information on the grading method. if ($strattempthighlight = quiz_report_highlighting_grading_method( $quiz, $this->qmsubselect, $options->onlygraded)) { echo '
                ' . $strattempthighlight . '
                '; } } // Define table columns. $columns = []; $headers = []; if (!$table->is_downloading() && $options->checkboxcolumn) { $columnname = 'checkbox'; $columns[] = $columnname; $headers[] = $table->checkbox_col_header($columnname); } $this->add_user_columns($table, $columns, $headers); $this->add_state_column($columns, $headers); if ($table->is_downloading()) { $this->add_time_columns($columns, $headers); } $this->add_grade_columns($quiz, $options->usercanseegrades, $columns, $headers); foreach ($questions as $id => $question) { if ($options->showqtext) { $columns[] = 'question' . $id; $headers[] = get_string('questionx', 'question', $question->displaynumber); } if ($options->showresponses) { $columns[] = 'response' . $id; $headers[] = get_string('responsex', 'quiz_responses', $question->displaynumber); } if ($options->showright) { $columns[] = 'right' . $id; $headers[] = get_string('rightanswerx', 'quiz_responses', $question->displaynumber); } } $table->define_columns($columns); $table->define_headers($headers); $table->sortable(true, 'uniqueid'); // Set up the table. $table->define_baseurl($options->get_url()); $this->configure_user_columns($table); $table->no_sorting('feedbacktext'); $table->column_class('sumgrades', 'bold'); $table->set_attribute('id', 'responses'); $table->collapsible(true); $table->out($options->pagesize, true); } return true; } } PK9B\8"--+report/responses/tests/fixtures/quizzes.csvnu[testnumber,preferredbehaviour 00,interactive PK9B\P vv/report/responses/tests/fixtures/questions00.csvnu[slot,type,which,cat,mark,overrides.hint.0.text,overrides.hint.0.format,overrides.hint.1.text,overrides.hint.1.format,overrides.hint.2.text,overrides.hint.2.format,overrides.hint.3.text,overrides.hint.3.format,overrides.shuffleanswers 1,random,,rand,1,"Hint 1",0,"Hint 2",0,"Hint 3",0,"Hint 4",0,0 ,shortanswer,,rand,1,"Hint 1",0,"Hint 2",0,"Hint 3",0,"Hint 4",0,0 ,numerical,,rand,1,"Hint 1",0,"Hint 2",0,"Hint 3",0,"Hint 4",0,0 2,calculatedsimple,sumwithvariants,maincat,1,"Hint 1",0,"Hint 2",0,"Hint 3",0,"Hint 4",0,0 3,match,,maincat,1,"Hint 1",0,"Hint 2",0,"Hint 3",0,"Hint 4",0,0 4,truefalse,,maincat,1,"",0,"",0,"",0,"",0,0 PK9B\ m-d/report/responses/tests/fixtures/responses00.csvnu[quizattempt,submittedstepno,slot.1.fraction,slot.1.responsesummary,slot.1.state,slot.2.responsesummary,slot.2.fraction,slot.2.state,slot.3.responsesummary,slot.3.fraction,slot.3.state,slot.4.responsesummary,slot.4.fraction,slot.4.state 1,1,,toad,todo,19.4,1.0,gradedright,"frog -> mammal; cat -> mammal; newt -> amphibian",,todo,True,1.0,gradedright 1,2,1.0,frog,gradedright,,,,"frog -> amphibian; cat -> mammal; newt -> amphibian",0.8888889,gradedright,,, 2,1,,frog,todo,-0.6,,todo,"frog -> amphibian; cat -> mammal; newt -> amphibian",,todo,True,,todo 3,1,0,3.142,gradedwrong,19.4,1.0,gradedright,"frog -> amphibian; cat -> mammal; newt -> amphibian",1.0,gradedright,False,0.0,gradedwrong 4,1,,2,todo,9.4,1.0,gradedright,"frog -> mammal; cat -> amphibian; newt -> amphibian",0.3333333,gradedpartial,False,0.0,gradedwrong 4,2,0,3.142,gradedwrong,,,,,,,,, 5,1,1.0,frog,gradedright,7.1,1.0,gradedright,"frog -> amphibian; cat -> mammal; newt -> amphibian",1.0,gradedright,True,1.0,gradedright 6,1,0,3.1,gradedwrong,9.1,1.0,gradedright,"frog -> amphibian; cat -> mammal; newt -> amphibian",1.0,gradedright,True,1.0,gradedright 7,1,0,2.5,gradedwrong,-0.2,0.0,gradedwrong,"frog -> amphibian; cat -> mammal; newt -> amphibian",1.0,gradedright,False,0,gradedwrong PK9B\- +report/responses/tests/fixtures/steps00.csvnu[quizattempt,firstname,lastname,randqs.1,responses.1.answer,responses.1.-submit,responses.1.-tryagain,variants.2,responses.2.answer,responses.2.-submit,responses.2.-tryagain,responses.3.frog,responses.3.cat,responses.3.newt,responses.3.-submit,responses.3.-tryagain,responses.4.answer,responses.4.-submit,responses.4.-tryagain,finished 1,John,Jones,shortanswer,toad,1,0,4,19.4,0,0,mammal,mammal,amphibian,0,0,1,0,0,0 1,John,Jones,shortanswer,toad,0,1,4,19.4,0,0,mammal,mammal,amphibian,0,0,1,0,0,0 1,John,Jones,shortanswer,frog,1,0,4,19.4,0,0,mammal,mammal,amphibian,0,0,1,0,0,0 1,John,Jones,shortanswer,toad,0,0,4,19.4,0,0,mammal,mammal,amphibian,1,0,1,0,0,0 1,John,Jones,shortanswer,toad,0,0,4,19.4,0,0,mammal,mammal,amphibian,0,1,1,0,0,0 1,John,Jones,shortanswer,frog,0,0,4,19.4,0,0,amphibian,mammal,amphibian,1,0,1,0,0,1 2,John,Smith,shortanswer,frog,0,0,6,-0.6,0,0,amphibian,mammal,amphibian,0,0,1,0,0,0 3,John,Vicars,numerical,3.142,0,0,4,19.4,0,0,amphibian,mammal,amphibian,0,0,0,0,0,1 4,John,Pacino,numerical,2,1,0,6,9.4,0,0,mammal,amphibian,amphibian,0,0,0,0,0,0 4,John,Pacino,numerical,2,0,1,6,9.4,0,0,mammal,amphibian,amphibian,0,0,0,0,0,0 4,John,Pacino,numerical,3.142,1,0,6,9.4,0,0,mammal,amphibian,amphibian,0,0,0,0,0,0 4,John,Pacino,numerical,3.142,0,0,6,9.4,0,0,mammal,amphibian,amphibian,0,0,0,1,0,0 4,John,Pacino,numerical,3.142,0,0,6,9.4,0,0,mammal,amphibian,amphibian,0,0,0,0,1,0 4,John,Pacino,numerical,3.142,0,0,6,9.4,0,0,mammal,amphibian,amphibian,0,0,1,1,0,1 5,John,Deniro,shortanswer,frog,0,0,9,7.1,0,0,amphibian,mammal,amphibian,0,0,1,0,0,1 6,John,Banks,numerical,3.1,0,0,7,9.1,0,0,amphibian,mammal,amphibian,0,0,1,0,0,1 7,John,Asimov,numerical,2.5,0,0,3,-0.2,0,0,amphibian,mammal,amphibian,0,0,0,0,0,1 PK9B\f :``0report/responses/tests/privacy/provider_test.phpnu[. /** * Privacy provider tests. * * @package quiz_responses * @copyright 2018 Andrew Nicols * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace quiz_responses\privacy; use core_privacy\local\metadata\collection; use quiz_responses\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/engine/questionattempt.php'); /** * Privacy provider tests class. * * @package quiz_responses * @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 { /** * When no preference exists, there should be no export. */ public function test_preference_unset(): void { global $USER; $this->resetAfterTest(); $this->setAdminUser(); provider::export_user_preferences($USER->id); $this->assertFalse(writer::with_context(\context_system::instance())->has_any_data()); } /** * Preference does exist. */ public function test_preference_bool_true(): void { global $USER; $this->resetAfterTest(); $this->setAdminUser(); set_user_preference('quiz_report_responses_qtext', true); set_user_preference('quiz_report_responses_resp', true); set_user_preference('quiz_report_responses_right', true); provider::export_user_preferences($USER->id); $writer = writer::with_context(\context_system::instance()); $this->assertTrue($writer->has_any_data()); $preferences = $writer->get_user_preferences('quiz_responses'); $this->assertNotEmpty($preferences->qtext); $this->assertEquals(transform::yesno(1), $preferences->qtext->value); $this->assertNotEmpty($preferences->resp); $this->assertEquals(transform::yesno(1), $preferences->resp->value); $this->assertNotEmpty($preferences->right); $this->assertEquals(transform::yesno(1), $preferences->right->value); } /** * Preference does exist. */ public function test_preference_bool_false(): void { global $USER; $this->resetAfterTest(); $this->setAdminUser(); set_user_preference('quiz_report_responses_qtext', false); set_user_preference('quiz_report_responses_resp', false); set_user_preference('quiz_report_responses_right', false); provider::export_user_preferences($USER->id); $writer = writer::with_context(\context_system::instance()); $this->assertTrue($writer->has_any_data()); $preferences = $writer->get_user_preferences('quiz_responses'); $this->assertNotEmpty($preferences->qtext); $this->assertEquals(transform::yesno(0), $preferences->qtext->value); $this->assertNotEmpty($preferences->resp); $this->assertEquals(transform::yesno(0), $preferences->resp->value); $this->assertNotEmpty($preferences->right); $this->assertEquals(transform::yesno(0), $preferences->right->value); } /** * Preference does exist. */ public function test_preference_bool_which_first(): void { global $USER; $this->resetAfterTest(); $this->setAdminUser(); set_user_preference('quiz_report_responses_which_tries', \question_attempt::FIRST_TRY); provider::export_user_preferences($USER->id); $writer = writer::with_context(\context_system::instance()); $this->assertTrue($writer->has_any_data()); $preferences = $writer->get_user_preferences('quiz_responses'); $expected = get_string("privacy:preference:which_tries:first", 'quiz_responses'); $this->assertNotEmpty($preferences->which_tries); $this->assertEquals($expected, $preferences->which_tries->value); } } PK9B\1J*dii*report/responses/tests/behat/basic.featurenu[@mod @mod_quiz @quiz @quiz_reponses Feature: Basic use of the Responses report In order to see how my students are progressing As a teacher I need to see all their quiz responses Background: Using the Responses report Given the following "users" exist: | username | firstname | lastname | | teacher | The | Teacher | | student1 | Student | One | | student2 | Student | Two | And the following "courses" exist: | fullname | shortname | | Course 1 | C1 | And the following "course enrolments" exist: | user | course | role | | teacher | C1 | editingteacher | | student1 | C1 | student | | student2 | C1 | student | And the following "question categories" exist: | contextlevel | reference | name | | Course | C1 | Test questions | And the following "activities" exist: | activity | name | intro | course | idnumber | preferredbehaviour | | quiz | Quiz 1 | Quiz 1 description | C1 | quiz1 | interactive | And the following "questions" exist: | questioncategory | qtype | name | template | | Test questions | numerical | NQ | pi3tries | And quiz "Quiz 1" contains the following questions: | question | page | maxmark | displaynumber | | NQ | 1 | 3.0 | 1a | @javascript Scenario: Report works when there are no attempts When I am on the "Quiz 1" "mod_quiz > Responses report" page logged in as teacher Then I should see "Attempts: 0" And I should see "Nothing to display" And I set the field "Attempts from" to "enrolled users who have a quiz attempt" @javascript Scenario: Report works when there are attempts Given user "student1" has started an attempt at quiz "Quiz 1" And user "student1" has checked answers in their attempt at quiz "Quiz 1": | slot | response | | 1 | 1.0 | And user "student1" has checked answers in their attempt at quiz "Quiz 1": | slot | response | | 1 | 3.0 | And user "student1" has checked answers in their attempt at quiz "Quiz 1": | slot | response | | 1 | 3.14 | And user "student1" has finished an attempt at quiz "Quiz 1" When I am on the "Quiz 1" "mod_quiz > Responses report" page logged in as teacher Then I should see "Attempts: 1" And I should see "Student One" And I should not see "Student Two" And I set the field "Attempts from" to "enrolled users who have, or do not have, a quiz attempt" And I set the field "Which tries" to "All tries" And I should see "Response 1a" And I press "Show report" And "Student OneReview attempt" row "Response 1a" column of "responses" table should contain "1.0" And "Student OneReview attempt" row "Status" column of "responses" table should contain "" And "Finished" row "Grade/100.00" column of "responses" table should contain "33.33" And "Finished" row "Response 1a" column of "responses" table should contain "3.14" And "Student Two" row "Status" column of "responses" table should contain "-" And "Student Two" row "Response 1a" column of "responses" table should contain "-" @javascript Scenario: Report does not allow strange combinations of options Given I am on the "Quiz 1" "mod_quiz > Responses report" page logged in as teacher And the "Which tries" "select" should be enabled When I set the field "Attempts from" to "enrolled users who do not have a quiz attempt" Then the "Which tries" "select" should be disabled PK9B\FI@report/responses/tests/responses_from_steps_walkthrough_test.phpnu[. namespace quiz_responses; use mod_quiz\quiz_attempt; use question_bank; /** * Quiz attempt walk through using data from csv file. * * @package quiz_responses * @category test * @copyright 2013 The Open University * @author Jamie Pratt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class responses_from_steps_walkthrough_test extends \mod_quiz\tests\attempt_walkthrough_testcase { #[\Override] public static function setUpBeforeClass(): void { global $CFG; parent::setUpBeforeClass(); require_once($CFG->dirroot . '/mod/quiz/report/statistics/report.php'); require_once($CFG->dirroot . '/mod/quiz/report/reportlib.php'); } #[\Override] protected static function get_test_files(): array { return ['questions', 'steps', 'responses']; } /** * Create a quiz add questions to it, walk through quiz attempts and then check results. * * @param array $quizsettings settings to override default settings for quiz created by generator. Taken from quizzes.csv. * @param array $csvdata of data read from csv file "questionsXX.csv", "stepsXX.csv" and "responsesXX.csv". * // phpcs:ignore moodle.PHPUnit.TestCaseProvider.dataProviderSyntaxMethodNotFound * @dataProvider get_data_for_walkthrough */ public function test_walkthrough_from_csv($quizsettings, $csvdata): void { $this->resetAfterTest(true); question_bank::get_qtype('random')->clear_caches_before_testing(); $this->create_quiz($quizsettings, $csvdata['questions']); $quizattemptids = $this->walkthrough_attempts($csvdata['steps']); foreach ($csvdata['responses'] as $responsesfromcsv) { $responses = $this->explode_dot_separated_keys_to_make_subindexs($responsesfromcsv); if (!isset($quizattemptids[$responses['quizattempt']])) { throw new \coding_exception("There is no quizattempt {$responses['quizattempt']}!"); } $this->assert_response_test($quizattemptids[$responses['quizattempt']], $responses); } } /** * Helper to assert a response. * * @param mixed $quizattemptid * @param mixed $responses * @throws \coding_exception */ protected function assert_response_test($quizattemptid, $responses): void { $quizattempt = quiz_attempt::create($quizattemptid); foreach ($responses['slot'] as $slot => $tests) { $slothastests = false; foreach ($tests as $test) { if ('' !== $test) { $slothastests = true; } } if (!$slothastests) { continue; } $qa = $quizattempt->get_question_attempt($slot); $stepswithsubmit = $qa->get_steps_with_submitted_response_iterator(); $step = $stepswithsubmit[$responses['submittedstepno']]; if (null === $step) { throw new \coding_exception("There is no step no {$responses['submittedstepno']} " . "for slot $slot in quizattempt {$responses['quizattempt']}!"); } foreach (['responsesummary', 'fraction', 'state'] as $column) { if (isset($tests[$column]) && $tests[$column] != '') { switch ($column) { case 'responsesummary': $actual = $qa->get_question()->summarise_response($step->get_qt_data()); break; case 'fraction': if (count($stepswithsubmit) == $responses['submittedstepno']) { // If this is the last step then we need to look at the fraction after the question has been // finished. $actual = $qa->get_fraction(); } else { $actual = $step->get_fraction(); } break; case 'state': if (count($stepswithsubmit) == $responses['submittedstepno']) { // If this is the last step then we need to look at the state after the question has been // finished. $state = $qa->get_state(); } else { $state = $step->get_state(); } $actual = substr(get_class($state), strlen('question_state_')); } $expected = $tests[$column]; $failuremessage = "Error in quizattempt {$responses['quizattempt']} in $column, slot $slot, " . "submittedstepno {$responses['submittedstepno']}"; $this->assertEquals($expected, $actual, $failuremessage); } } } } } PK9B\Kc  )report/responses/last_responses_table.phpnu[. use mod_quiz\local\reports\attempts_report_table; use mod_quiz\quiz_attempt; /** * This is a table subclass for displaying the quiz responses report. * * @copyright 2008 Jean-Michel Vedrine * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class quiz_last_responses_table extends attempts_report_table { /** * Constructor * @param stdClass $quiz * @param context $context * @param string $qmsubselect * @param quiz_responses_options $options * @param \core\dml\sql_join $groupstudentsjoins * @param \core\dml\sql_join $studentsjoins * @param array $questions * @param moodle_url $reporturl */ public function __construct($quiz, $context, $qmsubselect, quiz_responses_options $options, \core\dml\sql_join $groupstudentsjoins, \core\dml\sql_join $studentsjoins, $questions, $reporturl) { parent::__construct('mod-quiz-report-responses-report', $quiz, $context, $qmsubselect, $options, $groupstudentsjoins, $studentsjoins, $questions, $reporturl); } public function build_table() { if (!$this->rawdata) { return; } $this->strtimeformat = str_replace(',', ' ', get_string('strftimedatetime')); parent::build_table(); } public function col_sumgrades($attempt) { if ($attempt->state != quiz_attempt::FINISHED) { return '-'; } $grade = quiz_rescale_grade($attempt->sumgrades, $this->quiz); if ($this->is_downloading()) { return $grade; } $gradehtml = '' . $grade . ''; return $gradehtml; } public function data_col($slot, $field, $attempt) { if ($attempt->usageid == 0) { return '-'; } $value = $this->field_from_extra_data($attempt, $slot, $field); if (is_null($value)) { $summary = '-'; } else { $summary = trim($value); } if ($this->is_downloading() && $this->is_downloading() != 'html') { return $summary; } $summary = s($summary); if ($this->is_downloading() || $field != 'responsesummary') { return $summary; } return $this->make_review_link($summary, $attempt, $slot); } /** * Column text from the extra data loaded in load_extra_data(), before html formatting etc. * * @param stdClass $attempt * @param int $slot * @param string $field * @return string */ protected function field_from_extra_data($attempt, $slot, $field) { if (!isset($this->lateststeps[$attempt->usageid][$slot])) { return '-'; } return $this->lateststeps[$attempt->usageid][$slot]->$field; } public function other_cols($colname, $attempt) { if (preg_match('/^question(\d+)$/', $colname, $matches)) { return $this->data_col($matches[1], 'questionsummary', $attempt); } else if (preg_match('/^response(\d+)$/', $colname, $matches)) { return $this->data_col($matches[1], 'responsesummary', $attempt); } else if (preg_match('/^right(\d+)$/', $colname, $matches)) { return $this->data_col($matches[1], 'rightanswer', $attempt); } else { return parent::other_cols($colname, $attempt); } } protected function requires_extra_data() { return true; } protected function is_latest_step_column($column) { if (preg_match('/^(?:question|response|right)([0-9]+)/', $column, $matches)) { return $matches[1]; } return false; } /** * Get any fields that might be needed when sorting on date for a particular slot. * @param int $slot the slot for the column we want. * @param string $alias the table alias for latest state information relating to that slot. * @return string sql fragment to alias fields. */ protected function get_required_latest_state_fields($slot, $alias) { global $DB; return $DB->sql_order_by_text("{$alias}.questionsummary") . " AS question{$slot}, " . $DB->sql_order_by_text("{$alias}.rightanswer") . " AS right{$slot}, " . $DB->sql_order_by_text("{$alias}.responsesummary") . " AS response{$slot}"; } } PK9B\o|44!report/attemptsreport_options.phpnu[. /** * File only retained to prevent fatal errors in code that tries to require/include this. * * @todo MDL-76612 delete this file as part of Moodle 4.6 development. * @deprecated This file is no longer required in Moodle 4.2+. */ defined('MOODLE_INTERNAL') || die(); debugging('This file is no longer required in Moodle 4.2+. Please do not include/require it.', DEBUG_DEVELOPER); PK9B\1((report/grading/styles.cssnu[#page-mod-quiz-report #manualgradingform { width: 100%; } #page-mod-quiz-report #manualgradingform.mform br { clear: none; } #page-mod-quiz-report #manualgradingform.mform .clearfix:after { clear: none; } #page-mod-quiz-report #manualgradingform .que { margin-bottom: 0.7em; } PK9B\8Freport/grading/renderer.phpnu[. /** * Defines the renderer for the quiz_grading module. * * @package quiz_grading * @copyright 2018 Huong Nguyen * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ /** * The renderer for the quiz_grading module. * * @copyright 2018 Huong Nguyen * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class quiz_grading_renderer extends plugin_renderer_base { /** * Render no question notification. * * @param stdClass $quiz The quiz settings. * @param stdClass $cm The course-module for this quiz. * @param stdClass $context The quiz context. * @return string The HTML for the no questions message. */ public function render_quiz_no_question_notification($quiz, $cm, $context) { return quiz_no_questions_message($quiz, $cm, $context); } /** * Render no question need to grade notification. * * @throws coding_exception */ public function render_quiz_no_grade_question_notification() { return $this->notification(get_string('nothingfound', 'quiz_grading'), 'info', false); } /** * Render index display. * * @param string $linktext The text of the link. * @param moodle_url $listquestionurl Url of the page that list all questions. * @return string The HTML for the display heading. * @throws coding_exception */ public function render_display_index_heading($linktext, $listquestionurl) { $output = ''; $output .= $this->heading(get_string('questionsthatneedgrading', 'quiz_grading'), 3); $output .= html_writer::tag('p', html_writer::link($listquestionurl, $linktext), ['class' => 'toggleincludeauto']); return $output; } /** * Render questions list table. * * @param bool $includeauto True to show automatically graded questions. * @param array $data List of questions. * @param array $header List of table headers. * @return string The HTML for the question table. * @throws coding_exception */ public function render_questions_table($includeauto, $data, $header) { if (empty($data)) { return $this->render_quiz_no_grade_question_notification(); } $output = ''; $table = new html_table(); $table->class = 'generaltable'; $table->id = 'questionstograde'; $table->head = $header; $table->data = $data; $output .= html_writer::table($table); return $output; } /** * Render grade link for question. * * @param stdClass $counts * @param string $type Type of grade. * @param string $string Lang string. * @param string $component Lang string component. * @param moodle_url $gradequestionurl Url to grade question. * @return string The HTML for the question grade link. * @throws coding_exception */ public function render_grade_link($counts, $type, $string, $component, $gradequestionurl) { $output = ''; if ($counts->$type > 0) { $output .= ' ' . html_writer::link( $gradequestionurl, get_string($string, $component), ['class' => 'gradetheselink']); } return $output; } /** * Render grading page. * * @param stdClass $questioninfo Information of a question. * @param moodle_url $listquestionsurl Url of the page that list all questions. * @param quiz_grading_settings_form $filterform Question filter form. * @param stdClass $paginginfo Pagination information. * @param stdClass $pagingbar Pagination bar information. * @param moodle_url $formaction Form submit url. * @param array $hiddeninputs List of hidden input fields. * @param string $gradequestioncontent HTML string of question content. * @return string The HTML for the grading interface. * @throws coding_exception * @throws moodle_exception */ public function render_grading_interface($questioninfo, $listquestionsurl, $filterform, $paginginfo, $pagingbar, $formaction, $hiddeninputs, $gradequestioncontent) { $output = ''; $output .= question_engine::initialise_js(); $output .= $this->heading(get_string('gradingquestionx', 'quiz_grading', $questioninfo), 3); $output .= html_writer::tag('p', html_writer::link($listquestionsurl, get_string('backtothelistofquestions', 'quiz_grading')), ['class' => 'mdl-align']); $output .= $filterform->render(); $output .= $this->heading(get_string('gradingattemptsxtoyofz', 'quiz_grading', $paginginfo), 3); $output .= $this->render_paging_bar($pagingbar); $output .= html_writer::start_tag('form', [ 'method' => 'post', 'action' => $formaction, 'class' => 'mform', 'id' => 'manualgradingform' ]); $output .= html_writer::start_tag('div'); $output .= html_writer::input_hidden_params(new moodle_url('', $hiddeninputs)); $output .= $gradequestioncontent; $output .= html_writer::tag('div', html_writer::empty_tag('input', [ 'type' => 'submit', 'class' => 'btn btn-primary', 'value' => get_string('saveandnext', 'quiz_grading') ]), ['class' => 'mdl-align']); $output .= html_writer::end_tag('div') . html_writer::end_tag('form'); $output .= $this->render_paging_bar($pagingbar); // Add the form change checker. $this->page->requires->js_call_amd('core_form/changechecker', 'watchFormById', ['manualgradingform']); return $output; } /** * Render grade question content. * * @param question_usage_by_activity $questionusage The question usage that need to grade. * @param int $slot the number used to identify this question within this usage. * @param question_display_options $displayoptions the display options to use. * @param int $questionnumber the number of the question to check. * @param string $heading the question heading text. * @return string The HTML for the question display. */ public function render_grade_question($questionusage, $slot, $displayoptions, $questionnumber, $heading) { $output = ''; if ($heading) { $output .= $this->heading($heading, 4); } $output .= $questionusage->render_question($slot, $displayoptions, $questionnumber); return $output; } /** * Render paging bar. * * @param object $pagingbar Pagination bar information. * @return string The HTML for the question display. */ public function render_paging_bar(object $pagingbar): string { if ($pagingbar->count > $pagingbar->pagesize && $pagingbar->order != 'random') { return $this->paging_bar($pagingbar->count, $pagingbar->page, $pagingbar->pagesize, $pagingbar->pagingurl); } return ''; } } PK9B\9ewreport/grading/db/install.phpnu[. /** * Post-install script for the quiz manual grading report. * @package quiz_grading * @copyright 2013 Tim Hunt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); /** * Post-install script */ function xmldb_quiz_grading_install() { global $DB; $record = new stdClass(); $record->name = 'grading'; $record->displayorder = '6000'; $record->capability = 'mod/quiz:grade'; $DB->insert_record('quiz_reports', $record); } PK9B\SAAreport/grading/db/access.phpnu[. /** * Capability definitions for the quiz manual grading report. * * @package quiz_grading * @copyright 2010 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); $capabilities = [ // Is the user allowed to see the student's real names while grading? 'quiz/grading:viewstudentnames' => [ 'captype' => 'read', 'contextlevel' => CONTEXT_MODULE, 'legacy' => [ 'teacher' => CAP_ALLOW, 'editingteacher' => CAP_ALLOW ], 'clonepermissionsfrom' => 'mod/quiz:viewreports' ], // Is the user allowed to see the student's identity fields while grading? // Note that the name of this capability is now out-of-date, but to preserve // backwards compatibility, the name was not changed when the functionality was updated. 'quiz/grading:viewidnumber' => [ 'captype' => 'read', 'contextlevel' => CONTEXT_MODULE, 'legacy' => [ 'teacher' => CAP_ALLOW, 'editingteacher' => CAP_ALLOW ], 'clonepermissionsfrom' => 'mod/quiz:viewreports' ] ]; PK9B\ ~~'report/grading/lang/en/quiz_grading.phpnu[. /** * Strings for component 'quiz_grading', language 'en', branch 'MOODLE_20_STABLE' * * @package quiz_grading * @copyright 1999 onwards Martin Dougiamas {@link http://moodle.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ $string['alldoneredirecting'] = 'All selected attempts have been graded. Returning to the list of questions.'; $string['alreadygraded'] = 'Already graded'; $string['alsoshowautomaticallygraded'] = 'Also show questions that have been graded automatically'; $string['attemptstograde'] = 'Attempts to grade'; $string['automaticallygraded'] = 'Automatically graded'; $string['backtothelistofquestions'] = 'Back to the list of questions'; $string['cannotloadquestioninfo'] = 'Unable to load questiontype specific question information'; $string['cannotgradethisattempt'] = 'Cannot grade this attempt.'; $string['changeoptions'] = 'Change options'; $string['essayonly'] = 'The following questions need to be graded manually'; $string['invalidquestionid'] = 'Gradable question with ID {$a} not found'; $string['invalidattemptid'] = 'No such attempt ID exists'; $string['gradeall'] = 'grade all'; $string['gradeattemptsall'] = 'All ({$a})'; $string['gradeattemptsautograded'] = 'Those that have been graded automatically ({$a})'; $string['gradeattemptsmanuallygraded'] = 'Those that have previously been graded manually ({$a})'; $string['gradeattemptsneedsgrading'] = 'Those that need grading ({$a})'; $string['graded'] = '(graded)'; $string['gradenextungraded'] = 'Grade next {$a} ungraded attempts'; $string['gradeungraded'] = 'Grade all {$a} ungraded attempts'; $string['grading'] = 'Manual grading'; $string['grading:viewidnumber'] = 'See student identity fields while grading'; $string['grading:viewstudentnames'] = 'See student names while grading'; $string['gradingall'] = 'All {$a} attempts on this question.'; $string['gradingattempt'] = 'Attempt number {$a->attempt} for {$a->fullname}'; $string['gradingattemptwithcustomfields'] = 'Attempt number {$a->attempt} for {$a->fullname} ({$a->customfields})'; $string['gradingattemptsxtoyofz'] = 'Grading attempts {$a->from} to {$a->to} of {$a->of}'; $string['gradingnextungraded'] = 'Next {$a} ungraded attempts'; $string['gradingnotallowed'] = 'You do not have permission to manually grade responses in this quiz'; $string['gradingquestionx'] = 'Grading question {$a->number}: {$a->questionname}'; $string['gradingreport'] = 'Manual grading report'; $string['gradinguser'] = 'Attempts for {$a}'; $string['gradingungraded'] = '{$a} ungraded attempts'; $string['hideautomaticallygraded'] = 'Hide questions that have been graded automatically'; $string['inprogress'] = 'In progress'; $string['noquestionsfound'] = 'No manually graded questions found'; $string['nothingfound'] = 'Nothing to display'; $string['options'] = 'Options'; $string['orderattemptsby'] = 'Order attempts by'; $string['pluginname'] = 'Manual grading'; $string['privacy:preference:order'] = 'What order to show the attempts that need grading.'; $string['privacy:preference:pagesize'] = 'How many attempts to show on each page of the grading interface.'; $string['qno'] = 'Q #'; $string['questionname'] = 'Question name'; $string['questionsperpage'] = 'Questions per page'; $string['questionsthatneedgrading'] = 'Questions that need grading'; $string['questiontitle'] = 'Question {$a->number} : "{$a->name}" ({$a->openspan}{$a->gradedattempts}{$a->closespan} / {$a->totalattempts} attempts {$a->openspan}graded{$a->closespan}).'; $string['random'] = 'Random'; $string['saveandnext'] = 'Save and show next'; $string['showstudentnames'] = 'Show student names'; $string['tograde'] = 'To grade'; $string['total'] = 'Total'; $string['unknownquestion'] = 'Unknown question'; $string['updategrade'] = 'update grades'; PK9B\Ireport/grading/version.phpnu[. /** * Quiz grading report version information. * * @package quiz_grading * @copyright 2010 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 = 'quiz_grading'; PK9B\= +report/grading/classes/privacy/provider.phpnu[. /** * Privacy subsystem implementation for quiz_grading. * * @package quiz_grading * @copyright 2020 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace quiz_grading\privacy; use core_privacy\local\metadata\collection; use core_privacy\local\request\writer; defined('MOODLE_INTERNAL') || die(); /** * Privacy subsystem for quiz_grading. */ 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('quiz_grading_pagesize', 'privacy:preference:pagesize'); $collection->add_user_preference('quiz_grading_order', 'privacy:preference:order'); 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) { // Page size. $pagesize = get_user_preferences("quiz_grading_pagesize", null, $userid); if ($pagesize !== null) { writer::export_user_preference('quiz_grading', 'pagesize', $pagesize, get_string('privacy:preference:pagesize', 'quiz_grading')); } // Attempt order. $order = get_user_preferences("quiz_grading_order", null, $userid); if ($order !== null) { switch ($order) { case 'random': $order = get_string('random', 'quiz_grading'); break; case 'date': $order = get_string('date'); break; case 'studentfirstname': $order = get_string('studentfirstname', 'quiz_grading'); break; case 'studentlastname': $order = get_string('studentlastname', 'quiz_grading'); break; default: $order = \core_user\fields::get_display_name($order); } writer::export_user_preference('quiz_grading', 'order', $order, get_string('privacy:preference:order', 'quiz_grading')); } } } PK9B\ҍttreport/grading/report.phpnu[. use mod_quiz\local\reports\report_base; use mod_quiz\quiz_attempt; defined('MOODLE_INTERNAL') || die(); require_once($CFG->dirroot . '/mod/quiz/report/grading/gradingsettings_form.php'); /** * Quiz report to help teachers manually grade questions that need it. * * This report basically provides two screens: * - List question that might need manual grading (or optionally all questions). * - Provide an efficient UI to grade all attempts at a particular question. * * @copyright 2006 Gustav Delius * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class quiz_grading_report extends report_base { const DEFAULT_PAGE_SIZE = 5; const DEFAULT_ORDER = 'random'; /** @var string Positive integer regular expression. */ const REGEX_POSITIVE_INT = '/^[1-9]\d*$/'; /** @var array URL parameters for what is being displayed when grading. */ protected $viewoptions = []; /** @var int the current group, 0 if none, or NO_GROUPS_ALLOWED. */ protected $currentgroup; /** @var array from quiz_report_get_significant_questions. */ protected $questions; /** @var stdClass the course settings. */ protected $course; /** @var stdClass the course_module settings. */ protected $cm; /** @var stdClass the quiz settings. */ protected $quiz; /** @var context the quiz context. */ protected $context; /** @var quiz_grading_renderer Renderer of Quiz Grading. */ protected $renderer; /** @var string fragment of SQL code to restrict to the relevant users. */ protected $userssql; /** @var array extra user fields. */ protected $extrauserfields = []; public function display($quiz, $cm, $course) { $this->quiz = $quiz; $this->cm = $cm; $this->course = $course; // Get the URL options. $slot = optional_param('slot', null, PARAM_INT); $questionid = optional_param('qid', null, PARAM_INT); $grade = optional_param('grade', null, PARAM_ALPHA); $includeauto = optional_param('includeauto', false, PARAM_BOOL); if (!in_array($grade, ['all', 'needsgrading', 'autograded', 'manuallygraded'])) { $grade = null; } $pagesize = optional_param('pagesize', get_user_preferences('quiz_grading_pagesize', self::DEFAULT_PAGE_SIZE), PARAM_INT); $page = optional_param('page', 0, PARAM_INT); $order = optional_param('order', get_user_preferences('quiz_grading_order', self::DEFAULT_ORDER), PARAM_ALPHAEXT); // Assemble the options required to reload this page. $optparams = ['includeauto', 'page']; foreach ($optparams as $param) { if ($$param) { $this->viewoptions[$param] = $$param; } } if (!data_submitted() && !preg_match(self::REGEX_POSITIVE_INT, $pagesize)) { // We only validate if the user accesses the page via a cleaned-up GET URL here. throw new moodle_exception('invalidpagesize'); } if ($pagesize != self::DEFAULT_PAGE_SIZE) { $this->viewoptions['pagesize'] = $pagesize; } if ($order != self::DEFAULT_ORDER) { $this->viewoptions['order'] = $order; } // Check permissions. $this->context = context_module::instance($this->cm->id); require_capability('mod/quiz:grade', $this->context); $shownames = has_capability('quiz/grading:viewstudentnames', $this->context); // Whether the current user can see custom user fields. $showcustomfields = has_capability('quiz/grading:viewidnumber', $this->context); $userfieldsapi = \core_user\fields::for_identity($this->context)->with_name(); $customfields = []; foreach ($userfieldsapi->get_required_fields([\core_user\fields::PURPOSE_IDENTITY]) as $field) { $customfields[] = $field; } // Validate order. $orderoptions = array_merge(['random', 'date', 'studentfirstname', 'studentlastname'], $customfields); if (!in_array($order, $orderoptions)) { $order = self::DEFAULT_ORDER; } else if (!$shownames && ($order == 'studentfirstname' || $order == 'studentlastname')) { $order = self::DEFAULT_ORDER; } else if (!$showcustomfields && in_array($order, $customfields)) { $order = self::DEFAULT_ORDER; } if ($order == 'random') { $page = 0; } // Get the list of questions in this quiz. $this->questions = quiz_report_get_significant_questions($quiz); if ($slot && !array_key_exists($slot, $this->questions)) { throw new moodle_exception('unknownquestion', 'quiz_grading'); } // Process any submitted data. if ($data = data_submitted() && confirm_sesskey() && $this->validate_submitted_marks()) { // Changes done to handle attempts being missed from grading due to redirecting to new page. $attemptsgraded = $this->process_submitted_data(); $nextpagenumber = $page + 1; // If attempts need grading and one or more have now been graded, then page number should remain the same. if ($grade == 'needsgrading' && $attemptsgraded) { $nextpagenumber = $page; } redirect($this->grade_question_url($slot, $questionid, $grade, $nextpagenumber)); } // Get the group, and the list of significant users. $this->currentgroup = $this->get_current_group($cm, $course, $this->context); if ($this->currentgroup == self::NO_GROUPS_ALLOWED) { $this->userssql = []; } else { $this->userssql = get_enrolled_sql($this->context, ['mod/quiz:reviewmyattempts', 'mod/quiz:attempt'], $this->currentgroup); } $hasquestions = quiz_has_questions($this->quiz->id); if (!$hasquestions) { $this->print_header_and_tabs($cm, $course, $quiz, 'grading'); echo $this->renderer->render_quiz_no_question_notification($quiz, $cm, $this->context); return true; } if (!$slot) { $this->display_index($includeauto); return true; } // Display the grading UI for one question. // Make sure there is something to do. $counts = null; $statecounts = $this->get_question_state_summary([$slot]); foreach ($statecounts as $record) { if ($record->questionid == $questionid) { $counts = $record; break; } } // If not, redirect back to the list. if (!$counts || $counts->$grade == 0) { redirect($this->list_questions_url(), get_string('alldoneredirecting', 'quiz_grading')); } $this->display_grading_interface($slot, $questionid, $grade, $pagesize, $page, $shownames, $showcustomfields, $order, $counts); return true; } /** * Get the JOIN conditions needed so we only show attempts by relevant users. * * @return qubaid_join */ protected function get_qubaids_condition() { $where = "quiza.quiz = :mangrquizid AND quiza.preview = 0 AND quiza.state = :statefinished"; $params = ['mangrquizid' => $this->cm->instance, 'statefinished' => quiz_attempt::FINISHED]; $usersjoin = ''; $currentgroup = groups_get_activity_group($this->cm, true); $enrolleduserscount = count_enrolled_users($this->context, ['mod/quiz:reviewmyattempts', 'mod/quiz:attempt'], $currentgroup); if ($currentgroup) { $userssql = get_enrolled_sql($this->context, ['mod/quiz:reviewmyattempts', 'mod/quiz:attempt'], $currentgroup); if ($enrolleduserscount < 1) { $where .= ' AND quiza.userid = 0'; } else { $usersjoin = "JOIN ({$userssql[0]}) AS enr ON quiza.userid = enr.id"; $params += $userssql[1]; } } return new qubaid_join("{quiz_attempts} quiza $usersjoin ", 'quiza.uniqueid', $where, $params); } /** * Load the quiz_attempts rows corresponding to a list of question_usage ids. * * @param int[] $qubaids the question_usage ids of the quiz_attempts to load. * @return array quiz attempts, with added user name fields. */ protected function load_attempts_by_usage_ids($qubaids) { global $DB; list($asql, $params) = $DB->get_in_or_equal($qubaids); $params[] = quiz_attempt::FINISHED; $params[] = $this->quiz->id; $fields = 'quiza.*, '; $userfieldsapi = \core_user\fields::for_identity($this->context)->with_name(); $userfieldssql = $userfieldsapi->get_sql('u', false, '', 'userid', false); $fields .= $userfieldssql->selects; foreach ($userfieldsapi->get_required_fields([\core_user\fields::PURPOSE_IDENTITY]) as $userfield) { $this->extrauserfields[] = s($userfield); } $params = array_merge($userfieldssql->params, $params); $attemptsbyid = $DB->get_records_sql(" SELECT $fields FROM {quiz_attempts} quiza JOIN {user} u ON u.id = quiza.userid {$userfieldssql->joins} WHERE quiza.uniqueid $asql AND quiza.state = ? AND quiza.quiz = ?", $params); $attempts = []; foreach ($attemptsbyid as $attempt) { $attempts[$attempt->uniqueid] = $attempt; } return $attempts; } /** * Get the URL of the front page of the report that lists all the questions. * * @return moodle_url the URL. */ protected function base_url() { return new moodle_url('/mod/quiz/report.php', ['id' => $this->cm->id, 'mode' => 'grading']); } /** * Get the URL of the front page of the report that lists all the questions. * * @param bool $includeauto if not given, use the current setting, otherwise, * force a particular value of includeauto in the URL. * @return moodle_url the URL. */ protected function list_questions_url($includeauto = null) { $url = $this->base_url(); $url->params($this->viewoptions); if (!is_null($includeauto)) { $url->param('includeauto', $includeauto); } return $url; } /** * Get the URL to grade a batch of question attempts. * * @param int $slot * @param int $questionid * @param string $grade * @param int|bool $page = true, link to current page. false = omit page. * number = link to specific page. * @return moodle_url */ protected function grade_question_url($slot, $questionid, $grade, $page = true) { $url = $this->base_url(); $url->params(['slot' => $slot, 'qid' => $questionid, 'grade' => $grade]); $url->params($this->viewoptions); if (!$page) { $url->remove_params('page'); } else if (is_integer($page)) { $url->param('page', $page); } return $url; } /** * Renders the contents of one cell of the table on the index view. * * @param stdClass $counts counts of different types of attempt for this slot. * @param string $type the type of count to format. * @param string $string get_string identifier for the grading link text, if required. * @param string $component get_string component identifier for the grading link text, if required. * @return string HTML. */ protected function format_count_for_table($counts, $type, $string, $component) { $result = $counts->$type; if ($counts->$type > 0) { $gradeurl = $this->grade_question_url($counts->slot, $counts->questionid, $type); $result .= $this->renderer->render_grade_link($counts, $type, $string, $component, $gradeurl); } return $result; } /** * Display the report front page which summarises the number of attempts to grade. * * @param bool $includeauto whether to show automatically-graded questions. */ protected function display_index($includeauto) { global $PAGE, $OUTPUT; $this->print_header_and_tabs($this->cm, $this->course, $this->quiz, 'grading'); if ($groupmode = groups_get_activity_groupmode($this->cm)) { // Groups is being used. groups_print_activity_menu($this->cm, $this->list_questions_url()); } // Get the current group for the user looking at the report. $currentgroup = $this->get_current_group($this->cm, $this->course, $this->context); if ($currentgroup == self::NO_GROUPS_ALLOWED) { echo $OUTPUT->notification(get_string('notingroup')); return; } $statecounts = $this->get_question_state_summary(array_keys($this->questions)); if ($includeauto) { $linktext = get_string('hideautomaticallygraded', 'quiz_grading'); } else { $linktext = get_string('alsoshowautomaticallygraded', 'quiz_grading'); } echo $this->renderer->render_display_index_heading($linktext, $this->list_questions_url(!$includeauto)); $data = []; $header = []; $header[] = get_string('qno', 'quiz_grading'); $header[] = get_string('qtypeveryshort', 'question'); $header[] = get_string('questionname', 'quiz_grading'); $header[] = get_string('tograde', 'quiz_grading'); $header[] = get_string('alreadygraded', 'quiz_grading'); if ($includeauto) { $header[] = get_string('automaticallygraded', 'quiz_grading'); } $header[] = get_string('total', 'quiz_grading'); foreach ($statecounts as $counts) { if ($counts->all == 0) { continue; } if (!$includeauto && $counts->needsgrading == 0 && $counts->manuallygraded == 0) { continue; } $row = []; $row[] = $this->questions[$counts->slot]->displaynumber; $row[] = $PAGE->get_renderer('question', 'bank')->qtype_icon($this->questions[$counts->slot]->qtype); $row[] = format_string($counts->name); $row[] = $this->format_count_for_table($counts, 'needsgrading', 'gradeverb', 'moodle'); $row[] = $this->format_count_for_table($counts, 'manuallygraded', 'updategrade', 'quiz_grading'); if ($includeauto) { $row[] = $this->format_count_for_table($counts, 'autograded', 'updategrade', 'quiz_grading'); } $row[] = $this->format_count_for_table($counts, 'all', 'gradeall', 'quiz_grading'); $data[] = $row; } echo $this->renderer->render_questions_table($includeauto, $data, $header); } /** * Display the UI for grading attempts at one question. * * @param int $slot identifies which question to grade. * @param int $questionid identifies which question to grade. * @param string $grade type of attempts to grade. * @param int $pagesize number of questions to show per page. * @param int $page current page number. * @param bool $shownames whether student names should be shown. * @param bool $showcustomfields whether custom field values should be shown. * @param string $order preferred order of attempts. * @param stdClass $counts object that stores the number of each type of attempt. */ protected function display_grading_interface($slot, $questionid, $grade, $pagesize, $page, $shownames, $showcustomfields, $order, $counts) { if ($pagesize * $page >= $counts->$grade) { $page = 0; } // Prepare the options form. $hidden = [ 'id' => $this->cm->id, 'mode' => 'grading', 'slot' => $slot, 'qid' => $questionid, 'page' => $page, ]; if (array_key_exists('includeauto', $this->viewoptions)) { $hidden['includeauto'] = $this->viewoptions['includeauto']; } $mform = new quiz_grading_settings_form($hidden, $counts, $shownames, $showcustomfields, $this->context); // Tell the form the current settings. $settings = new stdClass(); $settings->grade = $grade; $settings->pagesize = $pagesize; $settings->order = $order; $mform->set_data($settings); if ($mform->is_submitted()) { if ($mform->is_validated()) { // If the form was submitted and validated, save the user preferences, and // redirect to a cleaned-up GET URL. set_user_preference('quiz_grading_pagesize', $pagesize); set_user_preference('quiz_grading_order', $order); redirect($this->grade_question_url($slot, $questionid, $grade, $page)); } else { // Set the pagesize back to the previous value, so the report page can continue the render // and the form can show the validation. $pagesize = get_user_preferences('quiz_grading_pagesize', self::DEFAULT_PAGE_SIZE); } } list($qubaids, $count) = $this->get_usage_ids_where_question_in_state( $grade, $slot, $questionid, $order, $page, $pagesize); $attempts = $this->load_attempts_by_usage_ids($qubaids); // Question info. $questioninfo = new stdClass(); $questioninfo->number = $this->questions[$slot]->number; $questioninfo->questionname = format_string($counts->name); // Paging info. $paginginfo = new stdClass(); $paginginfo->from = $page * $pagesize + 1; $paginginfo->to = min(($page + 1) * $pagesize, $count); $paginginfo->of = $count; $qubaidlist = implode(',', $qubaids); $this->print_header_and_tabs($this->cm, $this->course, $this->quiz, 'grading'); $gradequestioncontent = ''; foreach ($qubaids as $qubaid) { $attempt = $attempts[$qubaid]; $quba = question_engine::load_questions_usage_by_activity($qubaid); $displayoptions = quiz_get_review_options($this->quiz, $attempt, $this->context); $displayoptions->generalfeedback = question_display_options::HIDDEN; $displayoptions->history = question_display_options::HIDDEN; $displayoptions->manualcomment = question_display_options::EDITABLE; $gradequestioncontent .= $this->renderer->render_grade_question( $quba, $slot, $displayoptions, $this->questions[$slot]->number, $this->get_question_heading($attempt, $shownames, $showcustomfields) ); } $pagingbar = new stdClass(); $pagingbar->count = $count; $pagingbar->page = $page; $pagingbar->pagesize = $pagesize; $pagingbar->pagesize = $pagesize; $pagingbar->order = $order; $pagingbar->pagingurl = $this->grade_question_url($slot, $questionid, $grade, false); $hiddeninputs = [ 'qubaids' => $qubaidlist, 'slots' => $slot, 'sesskey' => sesskey() ]; echo $this->renderer->render_grading_interface( $questioninfo, $this->list_questions_url(), $mform, $paginginfo, $pagingbar, $this->grade_question_url($slot, $questionid, $grade, $page), $hiddeninputs, $gradequestioncontent ); } /** * When saving a grading page, are all the submitted marks valid? * * @return bool true if all valid, else false. */ protected function validate_submitted_marks() { $qubaids = optional_param('qubaids', null, PARAM_SEQUENCE); if (!$qubaids) { return false; } $qubaids = clean_param_array(explode(',', $qubaids), PARAM_INT); $slots = optional_param('slots', '', PARAM_SEQUENCE); if (!$slots) { $slots = []; } else { $slots = explode(',', $slots); } foreach ($qubaids as $qubaid) { foreach ($slots as $slot) { if (!question_engine::is_manual_grade_in_range($qubaid, $slot)) { return false; } } } return true; } /** * Save all submitted marks to the database. * * @return bool returns true if some attempts or all are graded. False, if none of the attempts are graded. */ protected function process_submitted_data(): bool { global $DB; $qubaids = optional_param('qubaids', null, PARAM_SEQUENCE); $assumedslotforevents = optional_param('slot', null, PARAM_INT); if (!$qubaids) { return false; } $qubaids = clean_param_array(explode(',', $qubaids), PARAM_INT); $attempts = $this->load_attempts_by_usage_ids($qubaids); $events = []; $transaction = $DB->start_delegated_transaction(); $attemptsgraded = false; foreach ($qubaids as $qubaid) { $attempt = $attempts[$qubaid]; $attemptobj = new quiz_attempt($attempt, $this->quiz, $this->cm, $this->course); // State of the attempt before grades are changed. $attemptoldtstate = $attemptobj->get_question_state($assumedslotforevents); $attemptobj->process_submitted_actions(time()); // Get attempt state after grades are changed. $attemptnewtstate = $attemptobj->get_question_state($assumedslotforevents); // Check if any attempts are graded. if (!$attemptsgraded && $attemptoldtstate->is_graded() != $attemptnewtstate->is_graded()) { $attemptsgraded = true; } // Add the event we will trigger later. $params = [ 'objectid' => $attemptobj->get_question_attempt($assumedslotforevents)->get_question_id(), 'courseid' => $attemptobj->get_courseid(), 'context' => context_module::instance($attemptobj->get_cmid()), 'other' => [ 'quizid' => $attemptobj->get_quizid(), 'attemptid' => $attemptobj->get_attemptid(), 'slot' => $assumedslotforevents, ], ]; $events[] = \mod_quiz\event\question_manually_graded::create($params); } $transaction->allow_commit(); // Trigger events for all the questions we manually marked. foreach ($events as $event) { $event->trigger(); } return $attemptsgraded; } /** * Load information about the number of attempts at various questions in each * summarystate. * * The results are returned as an two dimensional array $qubaid => $slot => $dataobject * * @param array $slots A list of slots for the questions you want to konw about. * @return array The array keys are slot,qestionid. The values are objects with * fields $slot, $questionid, $inprogress, $name, $needsgrading, $autograded, * $manuallygraded and $all. */ protected function get_question_state_summary($slots) { $dm = new question_engine_data_mapper(); return $dm->load_questions_usages_question_state_summary( $this->get_qubaids_condition(), $slots); } /** * Get a list of usage ids where the question with slot $slot, and optionally * also with question id $questionid, is in summary state $summarystate. Also * return the total count of such states. * * Only a subset of the ids can be returned by using $orderby, $limitfrom and * $limitnum. A special value 'random' can be passed as $orderby, in which case * $limitfrom is ignored. * * @param int $slot The slot for the questions you want to konw about. * @param int $questionid (optional) Only return attempts that were of this specific question. * @param string $summarystate 'all', 'needsgrading', 'autograded' or 'manuallygraded'. * @param string $orderby 'random', 'date', 'student' or 'idnumber'. * @param int $page implements paging of the results. * Ignored if $orderby = random or $pagesize is null. * @param int $pagesize implements paging of the results. null = all. * @return array with two elements, an array of usage ids, and a count of the total number. */ protected function get_usage_ids_where_question_in_state($summarystate, $slot, $questionid = null, $orderby = 'random', $page = 0, $pagesize = null) { $dm = new question_engine_data_mapper(); $extraselect = ''; if ($pagesize && $orderby != 'random') { $limitfrom = $page * $pagesize; } else { $limitfrom = 0; } $qubaids = $this->get_qubaids_condition(); $params = []; $userfieldsapi = \core_user\fields::for_identity($this->context)->with_name(); $userfieldssql = $userfieldsapi->get_sql('u', true, '', 'userid', true); $params = array_merge($params, $userfieldssql->params); $customfields = []; foreach ($userfieldsapi->get_required_fields([\core_user\fields::PURPOSE_IDENTITY]) as $field) { $customfields[] = $field; } if ($orderby === 'date') { list($statetest, $params) = $dm->in_summary_state_test( 'manuallygraded', false, 'mangrstate'); $extraselect = "( SELECT MAX(sortqas.timecreated) FROM {question_attempt_steps} sortqas WHERE sortqas.questionattemptid = qa.id AND sortqas.state $statetest ) as tcreated"; $orderby = "tcreated"; } else if ($orderby === 'studentfirstname' || $orderby === 'studentlastname' || in_array($orderby, $customfields)) { $qubaids->from .= " JOIN {user} u ON quiza.userid = u.id {$userfieldssql->joins}"; // For name sorting, map orderby form value to // actual column names; 'idnumber' maps naturally. if ($orderby === "studentlastname") { $orderby = "u.lastname, u.firstname"; } else if ($orderby === "studentfirstname") { $orderby = "u.firstname, u.lastname"; } else if (in_array($orderby, $customfields)) { // Sort order by current custom user field. $orderby = $userfieldssql->mappings[$orderby]; } } return $dm->load_questions_usages_where_question_in_state($qubaids, $summarystate, $slot, $questionid, $orderby, $params, $limitfrom, $pagesize, $extraselect); } /** * Initialise some parts of $PAGE and start output. * * @param stdClass $cm the course_module information. * @param stdClass $course the course settings. * @param stdClass $quiz the quiz settings. * @param string $reportmode the report name. */ public function print_header_and_tabs($cm, $course, $quiz, $reportmode = 'overview') { global $PAGE; $this->renderer = $PAGE->get_renderer('quiz_grading'); parent::print_header_and_tabs($cm, $course, $quiz, $reportmode); } /** * Get question heading. * * @param stdClass $attempt An instance of quiz_attempt. * @param bool $shownames True to show the student first/lastnames. * @param bool $showcustomfields Whether custom field values should be shown. * @return string The string text for the question heading. */ protected function get_question_heading(stdClass $attempt, bool $shownames, bool $showcustomfields): string { global $DB; $a = new stdClass(); $a->attempt = $attempt->attempt; $a->fullname = fullname($attempt); $customfields = []; foreach ($this->extrauserfields as $field) { if (strval($attempt->{$field}) !== '') { $customfields[] = s($attempt->{$field}); } } $a->customfields = implode(', ', $customfields); if ($shownames && $showcustomfields) { return get_string('gradingattemptwithcustomfields', 'quiz_grading', $a); } else if ($shownames) { return get_string('gradingattempt', 'quiz_grading', $a); } else if ($showcustomfields) { $a->fullname = $a->customfields; return get_string('gradingattempt', 'quiz_grading', $a); } else { return ''; } } } PK9B\ Wnn'report/grading/gradingsettings_form.phpnu[. /** * This file defines the setting form for the quiz grading report. * * @package quiz_grading * @copyright 2010 Tim Hunt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); require_once($CFG->libdir . '/formslib.php'); /** * Quiz grading report settings form. * * @copyright 2010 Tim Hunt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class quiz_grading_settings_form extends moodleform { /** @var bool whether the user has asked to include automatically graded attempts. */ protected $includeauto; /** @var array extra hidden fields to add to the form. Things like cm id. */ protected $hidden = []; /** @var stdClass data about how many attempts of each type their are. */ protected $counts; /** @var bool Whether the user has permission to see user names. */ protected $shownames; /** @var bool $showcustomfields whether custom field values should be shown. */ protected $showcustomfields; /** @var stdClass $context the quiz context. */ protected $context; /** * quiz_grading_settings_form constructor. * * @param array $hidden Array of options form. * @param stdClass $counts object that stores the number of each type of attempt. * @param bool $shownames whether student names should be shown. * @param bool $showcustomfields whether custom field values should be shown. * @param stdClass $context context object. */ public function __construct(array $hidden, stdClass $counts, bool $shownames, bool $showcustomfields, stdClass $context) { global $CFG; $this->includeauto = !empty($hidden['includeauto']); $this->hidden = $hidden; $this->counts = $counts; $this->shownames = $shownames; $this->showcustomfields = $showcustomfields; $this->context = $context; parent::__construct($CFG->wwwroot . '/mod/quiz/report.php'); } protected function definition() { $mform = $this->_form; $mform->addElement('header', 'options', get_string('options', 'quiz_grading')); $gradeoptions = []; foreach (['needsgrading', 'manuallygraded', 'autograded', 'all'] as $type) { if (empty($this->counts->$type)) { continue; } if ($type == 'autograded' && !$this->includeauto) { continue; } $gradeoptions[$type] = get_string('gradeattempts' . $type, 'quiz_grading', $this->counts->$type); } $mform->addElement('select', 'grade', get_string('attemptstograde', 'quiz_grading'), $gradeoptions); $mform->addElement('text', 'pagesize', get_string('questionsperpage', 'quiz_grading'), ['size' => 3]); $mform->addRule('pagesize', null, 'positiveint', null, 'client'); $mform->setType('pagesize', PARAM_INT); $orderoptions = [ 'random' => get_string('random', 'quiz_grading'), 'date' => get_string('date') ]; if ($this->shownames) { $orderoptions['studentfirstname'] = get_string('firstname'); $orderoptions['studentlastname'] = get_string('lastname'); } // If the current user can see custom user fields, add the custom user fields to the select menu. if ($this->showcustomfields) { $userfieldsapi = \core_user\fields::for_identity($this->context); foreach ($userfieldsapi->get_required_fields([\core_user\fields::PURPOSE_IDENTITY]) as $field) { $orderoptions[s($field)] = \core_user\fields::get_display_name(s($field)); } } $mform->addElement('select', 'order', get_string('orderattemptsby', 'quiz_grading'), $orderoptions); foreach ($this->hidden as $name => $value) { $mform->addElement('hidden', $name, $value); if ($name == 'mode') { $mform->setType($name, PARAM_ALPHA); } else { $mform->setType($name, PARAM_INT); } } $mform->addElement('submit', 'submitbutton', get_string('changeoptions', 'quiz_grading')); } } PK9B\J9" " .report/grading/tests/privacy/provider_test.phpnu[. /** * Privacy provider tests. * * @package quiz_grading * @copyright 2020 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace quiz_grading\privacy; use core_privacy\local\metadata\collection; use quiz_grading\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/engine/questionattempt.php'); /** * Privacy provider tests class. */ final class provider_test extends \core_privacy\tests\provider_testcase { /** * When no preference exists, there should be no export. */ public function test_preference_unset(): void { global $USER; $this->resetAfterTest(); $this->setAdminUser(); provider::export_user_preferences($USER->id); $this->assertFalse(writer::with_context(\context_system::instance())->has_any_data()); } /** * Preference does exist. */ public function test_preference_bool_true(): void { global $USER; $this->resetAfterTest(); $this->setAdminUser(); set_user_preference('quiz_grading_pagesize', 42); set_user_preference('quiz_grading_order', 'random'); provider::export_user_preferences($USER->id); $writer = writer::with_context(\context_system::instance()); $this->assertTrue($writer->has_any_data()); $preferences = $writer->get_user_preferences('quiz_grading'); $this->assertNotEmpty($preferences->pagesize); $this->assertEquals(42, $preferences->pagesize->value); $this->assertNotEmpty($preferences->order); $this->assertEquals('Random', $preferences->order->value); } /** * Preference does exist using user custom fields. */ public function test_preference_bool_true_for_user_customfields(): void { global $USER; $this->resetAfterTest(); $this->setAdminUser(); $customfields = ['username', 'idnumber', 'email', 'profile_field_frog']; foreach ($customfields as $customfield) { set_user_preference('quiz_grading_order', $customfield); provider::export_user_preferences($USER->id); $writer = writer::with_context(\context_system::instance()); $this->assertTrue($writer->has_any_data()); $preferences = $writer->get_user_preferences('quiz_grading'); $this->assertNotEmpty($preferences->order); $this->assertEquals(\core_user\fields::get_display_name($customfield), $preferences->order->value); } } } PK9B\5555*report/grading/tests/behat/grading.featurenu[@mod @mod_quiz @quiz @quiz_grading Feature: Basic use of the Manual grading report In order to easily find students attempts that need manual grading As a teacher I need to use the manual grading report Background: Given the following "custom profile fields" exist: | datatype | shortname | name | | text | username | Username | | text | email | Email address | | text | idnumber | ID number | | text | frog | Favourite frog | And the following config values are set as admin: | showuseridentity | username,idnumber,email,profile_field_frog | And the following "users" exist: | username | firstname | lastname | email | idnumber | profile_field_frog | | teacher1 | T1 | Teacher1 | teacher1@example.com | T1000 | | | marker | M1 | Marker | marker@example.com | M1000 | | | student1 | S1 | Student1 | student1@example.com | S1000 | little yellow frog | | student2 | S2 | Student2 | student2@example.com | S2000 | | And the following "courses" exist: | fullname | shortname | category | | Course 1 | C1 | 0 | And the following "course enrolments" exist: | user | course | role | | teacher1 | C1 | editingteacher | | marker | C1 | teacher | | student1 | C1 | student | | student2 | C1 | student | And the following "groupings" exist: | name | course | idnumber | | Tutor groups | C1 | tging | And the following "groups" exist: | name | course | idnumber | | Tutor group | C1 | tg | | Marker group | C1 | mg | And the following "grouping groups" exist: | grouping | group | | tging | tg | And the following "group members" exist: | user | group | | teacher1 | tg | | student1 | tg | | marker | mg | | student2 | mg | And the following "question categories" exist: | contextlevel | reference | name | | Course | C1 | Test questions | And the following "questions" exist: | questioncategory | qtype | name | questiontext | answer 1 | grade | | Test questions | shortanswer | Short answer 001 | Where is the capital city of France? | Paris | 100% | And the following "activities" exist: | activity | name | course | idnumber | groupmode | grouping | | quiz | Quiz 1 | C1 | quiz1 | 1 | tging | | quiz | Quiz 2 | C1 | quiz2 | 1 | tging | And quiz "Quiz 1" contains the following questions: | question | page | displaynumber | | Short answer 001 | 1 | 1a | Scenario: Manual grading report without attempts When I am on the "Quiz 1" "mod_quiz > Manual grading report" page logged in as "teacher1" Then I should see "Separate groups (Tutor groups)" And I should see "All participants" And I should see "Quiz 1" And I should see "Questions that need grading" And I should see "Nothing to display" And I follow "Also show questions that have been graded automatically" And I should see "Nothing to display" Scenario: Manual grading report with attempts Given user "student1" has attempted "Quiz 1" with responses: | slot | response | | 1 | Paris | And I reload the page When I am on the "Quiz 1" "mod_quiz > Manual grading report" page logged in as "teacher1" Then I should see "Separate groups (Tutor groups)" And I should see "All participants" And I should see "Quiz 1" And I follow "Also show questions that have been graded automatically" And I should see "Short answer 001" And "Short answer 001" row "To grade" column of "questionstograde" table should contain "0" And "Short answer 001" row "Already graded" column of "questionstograde" table should contain "0" And I should see "Short answer 001" And "Short answer 001" row "To grade" column of "questionstograde" table should contain "0" And "Short answer 001" row "Already graded" column of "questionstograde" table should contain "0" # Go to the grading page. And I click on "update grades" "link" in the "Short answer 001" "table_row" And I should see "Grading attempts 1 to 1 of 1" # Test the display options. And I set the field "Order attempts by" to "ID number" And I press "Change options" # General feedback for Short answer 001 displays. And I should see "That is a bad answer." And I should see "The correct answer is: frog" # Adjust the mark for Student1 And I set the field "Comment" to "I have adjusted your mark to 0.6" And I set the field "Mark" to "0.6" And I press "Save and show next" And I should see "All selected attempts have been graded. Returning to the list of questions." And "Short answer 001" row "Q #" column of "questionstograde" table should contain "1a" And "Short answer 001" row "To grade" column of "questionstograde" table should contain "0" And "Short answer 001" row "Already graded" column of "questionstograde" table should contain "1" Scenario: Manual grading settings are remembered as user preferences Given user "student1" has attempted "Quiz 1" with responses: | slot | response | | 1 | Paris | When I am on the "Quiz 1" "mod_quiz > Manual grading report" page logged in as "teacher1" And I follow "Also show questions that have been graded automatically" And I click on "update grades" "link" in the "Short answer 001" "table_row" And I set the following fields to these values: | Questions per page | 42 | | Order attempts by | Date | And I press "Change options" And I log out And I am on the "Quiz 1" "mod_quiz > Manual grading report" page logged in as "teacher1" And I follow "Also show questions that have been graded automatically" And I click on "update grades" "link" in the "Short answer 001" "table_row" Then the following fields match these values: | Questions per page | 42 | | Order attempts by | Date | @javascript Scenario: Manual grading settings are validated Given user "student1" has attempted "Quiz 1" with responses: | slot | response | | 1 | Paris | And I am on the "Quiz 1" "mod_quiz > Manual grading report" page logged in as "teacher1" And I follow "Also show questions that have been graded automatically" And I click on "update grades" "link" in the "Short answer 001" "table_row" When I set the following fields to these values: | Questions per page | 0 | Then I should see "You must enter a whole number that is greater than 0." And I set the following fields to these values: | Questions per page | -1 | And I press "Change options" And I should see "You must enter a whole number that is greater than 0." And I set the following fields to these values: | Questions per page | abc | And I press "Change options" And I should see "You must enter a whole number that is greater than 0." And I set the following fields to these values: | Questions per page | 1 | And I press "Change options" @javascript Scenario: Teacher can see user custom filed columns as additional user identity Given user "student1" has attempted "Quiz 1" with responses: | slot | response | | 1 | Paris | When I am on the "Quiz 1" "mod_quiz > Manual grading report" page logged in as "teacher1" And I follow "Also show questions that have been graded automatically" And I click on "update grades" "link" in the "Short answer 001" "table_row" Then I should see "Attempt number 1 for S1 Student1 (student1, S1000, student1@example.com, little yellow frog)" And I should not see "You must enter a whole number that is greater than 0." Scenario: A marker cannot access the report in separate group Given user "student1" has attempted "Quiz 1" with responses: | slot | response | | 1 | frog | And user "student2" has attempted "Quiz 1" with responses: | slot | response | | 1 | Duck | When I am on the "Quiz 1" "mod_quiz > Manual grading report" page logged in as "marker" Then I should see "Quiz 1" And I should see "Separate groups: All participants" Then I should see "Sorry, but you need to be part of a group to see this page." @javascript Scenario: Manual grading report with attempts to be graded Given the following "questions" exist: | questioncategory | qtype | name | user | questiontext | | Test questions | essay | Essay Q1 | admin | Question 1 text | And quiz "Quiz 2" contains the following questions: | question | page | | Essay Q1 | 1 | When I am on the "Quiz 2" "mod_quiz > View" page logged in as "student1" And I press "Attempt quiz" And I set the field "Answer text Question 1" to "This is my attempt 1" And I follow "Finish attempt ..." And I press "Submit all and finish" And I click on "Submit all and finish" "button" in the "Submit all your answers and finish?" "dialogue" And I click on "Finish review" "link" And I press "Re-attempt quiz" And I set the field "Answer text Question 1" to "This is my attempt 2" And I follow "Finish attempt ..." And I press "Submit all and finish" And I click on "Submit all and finish" "button" in the "Submit all your answers and finish?" "dialogue" And I click on "Finish review" "link" And I press "Re-attempt quiz" And I set the field "Answer text Question 1" to "This is my attempt 3" And I follow "Finish attempt ..." And I press "Submit all and finish" And I click on "Submit all and finish" "button" in the "Submit all your answers and finish?" "dialogue" And I am on the "Quiz 2" "mod_quiz > Manual grading report" page logged in as "teacher1" And I follow "Also show questions that have been graded automatically" And I should see "Essay Q1" And "Essay Q1" row "To grade" column of "questionstograde" table should contain "3" And "Essay Q1" row "Already graded" column of "questionstograde" table should contain "0" # Go to the grading page. And I click on "Grade" "link" in the "Essay Q1" "table_row" And I should see "Grading attempts 1 to 3 of 3" And I set the following fields to these values: | Questions per page | 1 | | Order attempts by | ID number | And I press "Change options" And I should see "Grading attempts 1 to 1 of 3" # Adjust the mark for Student1 And I set the field "Comment" to "I have adjusted your mark to 0.6" And I set the field "Mark" to "0.6" And I press "Save and show next" Then I should see "Grading attempts 1 to 1 of 2" And I press "Save and show next" And I should see "Grading attempts 2 to 2 of 2" @javascript Scenario: Manually grade quiz with multiple questions Given the following "questions" exist: | questioncategory | qtype | name | questiontext | defaultmark | | Test questions | essay | Essay 1 | First question | 20 | | Test questions | essay | Essay 2 | Second question | 20 | | Test questions | truefalse | TF1 | Third question | | And quiz "Quiz 2" contains the following questions: | question | page | | Essay 1 | 1 | | Essay 2 | 2 | | TF1 | 2 | And user "student1" has attempted "Quiz 2" with responses: | slot | response | | 1 | Answer to Essay 1 | | 2 | Answer to Essay 2 | | 3 | True | And I am on the "Quiz 2 > student1 > Attempt 1" "mod_quiz > Attempt review" page logged in as "teacher1" And I follow "Make comment or override mark" And I switch to "commentquestion" window And I set the field "Mark" to "15" And I press "Save" and switch to main window When I am on the "Quiz 2" "mod_quiz > Manual grading report" page And I click on "Also show questions that have been graded automatically" "link" # Confirm that Essay 1 is already graded, Essay 2 is not yet graded and that TF1 is automatically graded. Then the following should exist in the "questionstograde" table: | Question name | To grade | Already graded | Automatically graded | | Essay 1 | 0 | 1 | 0 | | Essay 2 | 1 | 0 | 0 | | TF1 | 0 | 0 | 1 | # Grade the not yet graded Essay 2. And I click on "Grade" "link" in the "Essay 2" "table_row" And I set the field "Mark" to "20" And I press "Save and show next" # Update the already graded Essay 1. And I click on "grade all" "link" in the "Essay 1" "table_row" And I set the field "Mark" to "5" And I press "Save and show next" # Confirm that each question is graded for the corresponding student. And I am on the "Quiz 2" "mod_quiz > Grades report" page And the following should exist in the "generaltable" table: | Username | Q. 1 | Q. 2 | Q. 3 | | student1 | 12.50 | 50.00 | - | PK9B\syy report/statistics/db/install.phpnu[. /** * Post-install script for the quiz statistics report. * @package quiz_statistics * @copyright 2010 Petr Skoda (http://skodak.org) * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); /** * Post-install script */ function xmldb_quiz_statistics_install() { global $DB; $dbman = $DB->get_manager(); $record = new stdClass(); $record->name = 'statistics'; $record->displayorder = 8000; $record->capability = 'quiz/statistics:view'; if ($dbman->table_exists('quiz_reports')) { $DB->insert_record('quiz_reports', $record); } else { $DB->insert_record('quiz_report', $record); } } PK9B\%greport/statistics/db/hooks.phpnu[. /** * Hook callback definitions for quiz_statistics * * @package quiz_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 */ defined('MOODLE_INTERNAL') || die(); $callbacks = [ [ 'hook' => mod_quiz\hook\structure_modified::class, 'callback' => quiz_statistics\hook_callbacks::class . '::quiz_structure_modified', 'priority' => 500, ], [ 'hook' => mod_quiz\hook\attempt_state_changed::class, 'callback' => quiz_statistics\hook_callbacks::class . '::quiz_attempt_submitted_or_deleted', 'priority' => 500, ], ]; PK9B\Kreport/statistics/db/access.phpnu[. /** * Capability definitions for the quiz statistics report. * * @package quiz_statistics * @copyright 2008 Jamie Pratt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); $capabilities = [ 'quiz/statistics:view' => [ 'captype' => 'read', 'contextlevel' => CONTEXT_MODULE, 'archetypes' => [ 'teacher' => CAP_ALLOW, 'editingteacher' => CAP_ALLOW, 'manager' => CAP_ALLOW ], 'clonepermissionsfrom' => 'mod/quiz:viewreports' ] ]; PK9B\)&qq report/statistics/db/upgrade.phpnu[. /** * Post-install script for the quiz statistics report. * * @package quiz_statistics * @copyright 2008 Jamie Pratt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ /** * Quiz statistics report upgrade code. */ function xmldb_quiz_statistics_upgrade($oldversion) { global $CFG, $DB; $dbman = $DB->get_manager(); // 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. if ($oldversion < 2025041401) { // Define index hashcode (not unique) to be added to quiz_statistics. $table = new xmldb_table('quiz_statistics'); $index = new xmldb_index('hashcode', XMLDB_INDEX_NOTUNIQUE, ['hashcode']); // Conditionally launch add index hashcode. if (!$dbman->index_exists($table, $index)) { $dbman->add_index($table, $index); } // Statistics savepoint reached. upgrade_plugin_savepoint(true, 2025041401, 'quiz', 'statistics'); } return true; } PK9B\ report/statistics/db/install.xmlnu[
                PK9B\ZZ-report/statistics/lang/en/quiz_statistics.phpnu[. /** * Strings for component 'quiz_statistics', language 'en', branch 'MOODLE_20_STABLE' * * @package quiz_statistics * @copyright 1999 onwards Martin Dougiamas {@link http://moodle.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ $string['actualresponse'] = 'Actual response'; $string['allattempts'] = 'all attempts'; $string['allattemptsavg'] = 'Average grade of all attempts'; $string['allattemptscount'] = 'Total number of complete graded attempts'; $string['analysisnameonly'] = '"{$a->name}"'; $string['analysisno'] = '({$a->number}) "{$a->name}"'; $string['analysisnovariant'] = '({$a->number}) "{$a->name}" variant {$a->variant}'; $string['analysisofresponses'] = 'Analysis of responses'; $string['analysisofresponsesfor'] = 'Analysis of responses for {$a}'; $string['analysisvariant'] = '"{$a->name}" variant {$a->variant}'; $string['attempts'] = 'Attempts'; $string['attemptsall'] = 'all attempts'; $string['attemptsfirst'] = 'first attempt'; $string['backtoquizreport'] = 'Back to main statistics report page.'; $string['calculatefrom'] = 'Calculate statistics from'; $string['calculatingallstats'] = 'Calculating statistics for quiz, questions and analysing response data'; $string['cic'] = 'Coefficient of internal consistency (for {$a})'; $string['completestatsfilename'] = 'completestats'; $string['count'] = 'Count'; $string['counttryno'] = 'Count Try {$a}'; $string['coursename'] = 'Course name'; $string['detailedanalysis'] = 'More detailed analysis of the responses to this question'; $string['detailedanalysisforvariant'] = 'More detailed analysis of the responses to variant {$a} of this question'; $string['discrimination_index'] = 'Discrimination index'; $string['discriminative_efficiency'] = 'Discriminative efficiency'; $string['downloadeverything'] = 'Download full report as'; $string['duration'] = 'Open for'; $string['effective_weight'] = 'Effective weight'; $string['errordeleting'] = 'Error deleting old {$a} records.'; $string['errormedian'] = 'Error fetching median'; $string['errorpowerquestions'] = 'Error fetching data to calculate variance for question grades'; $string['errorpowers'] = 'Error fetching data to calculate variance for quiz grades'; $string['errorrandom'] = 'Error getting sub item data'; $string['errorratio'] = 'Error ratio (for {$a})'; $string['errorstatisticsquestions'] = 'Error fetching data to calculate statistics for question grades'; $string['facility'] = 'Facility index'; $string['firstattempts'] = 'first attempts'; $string['firstattemptsavg'] = 'Average grade of first attempts'; $string['firstattemptscount'] = 'Number of complete graded first attempts'; $string['frequency'] = 'Frequency'; $string['getstatslockprogress'] = 'Waiting for task in progress. Please wait or try again later.'; $string['getstatslocktimeout'] = 'Statistics calculation lock timeout'; $string['getstatslocktimeoutdesc'] = 'How many seconds to wait for a lock when attempting to perform a statistics calculation for a quiz. This setting primarily exists for testing, do not modify it unless you know what you are doing.'; $string['highestattempts'] = 'highest graded attempt'; $string['highestattemptsavg'] = 'Average grade of highest graded attempts'; $string['intended_weight'] = 'Intended weight'; $string['kurtosis'] = 'Score distribution kurtosis (for {$a})'; $string['lastattempts'] = 'last attempt'; $string['lastattemptsavg'] = 'Average grade of last attempts'; $string['lastcalculated'] = 'Last calculated {$a->lastcalculated} ago there have been {$a->count} attempts since then.'; $string['maximumfacility'] = 'Maximum facility'; $string['median'] = 'Median grade (for {$a})'; $string['medianfacility'] = 'Median facility'; $string['minimumfacility'] = 'Minimum facility'; $string['modelresponse'] = 'Model response'; $string['nameforvariant'] = 'Variant {$a->variant} of {$a->name}'; $string['negcovar'] = 'Negative covariance of grade with total attempt grade'; $string['negcovar_help'] = 'This question\'s grade for this set of attempts on the quiz varies in an opposite way to the overall attempt grade. This means overall attempt grade tends to be below average when the grade for this question is above average and vice-versa. Our equation for effective question weight cannot be calculated in this case. The calculations for effective question weight for other questions in this quiz are the effective question weight for these questions if the highlighted questions with a negative covariance are given a maximum grade of zero. If you edit a quiz and give these question(s) with negative covariance a max grade of zero then the effective question weight of these questions will be zero and the real effective question weight of other questions will be as calculated now.'; $string['nogradedattempts'] = 'No attempts have been made at this quiz, or all attempts have questions that need manual grading.'; $string['nostudentsingroup'] = 'There are no students in this group yet'; $string['nostats'] = 'Could not complete the statistics calculation. There may be a long-running calculation in progress. Please try again later.'; $string['optiongrade'] = 'Partial credit'; $string['partofquestion'] = 'Part of question'; $string['pluginname'] = 'Statistics'; $string['privacy:metadata'] = 'Although the Quiz Statistics plugin has database tables, the data is aggregate data and does not describe a unique indidividual.'; $string['position'] = 'Position'; $string['positions'] = 'Position(s)'; $string['questioninformation'] = 'Question information'; $string['questionname'] = 'Question name'; $string['questionnumber'] = 'Q#'; $string['questionstatistics'] = 'Question statistics'; $string['questionstatsfilename'] = 'questionstats'; $string['questiontype'] = 'Question type'; $string['quizinformation'] = 'Quiz information'; $string['quizname'] = 'Quiz name'; $string['quizoverallstatistics'] = 'Quiz overall statistics'; $string['quizstructureanalysis'] = 'Quiz structure analysis'; $string['random_guess_score'] = 'Random guess score'; $string['rangeofvalues'] = 'Range of statistics for these questions'; $string['rangebetween'] = '{$a->min} − {$a->max}'; $string['recalculatenow'] = 'Recalculate now'; $string['recalculatetask'] = 'Recalculate question statistics'; $string['reportsettings'] = 'Statistics calculation settings'; $string['response'] = 'Response'; $string['slotstructureanalysis'] = 'Structural analysis for question number {$a}'; $string['skewness'] = 'Score distribution skewness (for {$a})'; $string['standarddeviation'] = 'Standard deviation (for {$a})'; $string['standarddeviationq'] = 'Standard deviation'; $string['standarderror'] = 'Standard error (for {$a})'; $string['statistics'] = 'Statistics'; $string['statisticsreport'] = 'Statistics report'; $string['statisticsreportgraph'] = 'Statistics for question positions'; $string['statistics:view'] = 'View statistics report'; $string['statsfor'] = 'Quiz statistics (for {$a})'; $string['variant'] = 'Variant'; $string['viewanalysis'] = 'View details'; $string['whichtries'] = 'Analyze responses for'; PK9B\aw w report/statistics/lib.phpnu[. /** * Standard plugin entry points of the quiz statistics report. * * @package quiz_statistics * @copyright 2011 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); /** * Serve questiontext files in the question text when they are displayed in this report. * * @package quiz_statistics * @category files * @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 quiz_statistics_question_preview_pluginfile($previewcontext, $questionid, $filecontext, $filecomponent, $filearea, $args, $forcedownload, $options = []) { global $CFG; require_once($CFG->dirroot . '/mod/quiz/locallib.php'); list($context, $course, $cm) = get_context_info_array($previewcontext->id); require_login($course, false, $cm); // Assume only trusted people can see this report. There is no real way to // validate questionid, becuase of the complexity of random quetsions. require_capability('quiz/statistics:view', $context); $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); } PK9B\w.report/statistics/version.phpnu[. /** * Quiz statistics report version information. * * @package quiz_statistics * @copyright 2008 Jamie Pratt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); $plugin->version = 2025041401; $plugin->requires = 2025040800; $plugin->component = 'quiz_statistics'; PK9B\P#report/statistics/statisticslib.phpnu[. /** * Common functions for the quiz statistics report. * * @package quiz_statistics * @copyright 2013 The Open University * @author James Pratt me@jamiep.org * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ use mod_quiz\quiz_attempt; defined('MOODLE_INTERNAL') || die; /** * SQL to fetch relevant 'quiz_attempts' records. * * @param int $quizid quiz id to get attempts for * @param \core\dml\sql_join $groupstudentsjoins Contains joins, wheres, params, empty if not using groups * @param string $whichattempts which attempts to use, represented internally as one of the constants as used in * $quiz->grademethod ie. * QUIZ_GRADEAVERAGE, QUIZ_GRADEHIGHEST, QUIZ_ATTEMPTLAST or QUIZ_ATTEMPTFIRST * we calculate stats based on which attempts would affect the grade for each student. * @param bool $includeungraded whether to fetch ungraded attempts too * @return array FROM and WHERE sql fragments and sql params */ function quiz_statistics_attempts_sql($quizid, \core\dml\sql_join $groupstudentsjoins, $whichattempts = QUIZ_GRADEAVERAGE, $includeungraded = false) { $fromqa = "{quiz_attempts} quiza "; $whereqa = 'quiza.quiz = :quizid AND quiza.preview = 0 AND quiza.state = :quizstatefinished'; $qaparams = ['quizid' => (int)$quizid, 'quizstatefinished' => quiz_attempt::FINISHED]; if (!empty($groupstudentsjoins->joins)) { $fromqa .= "\nJOIN {user} u ON u.id = quiza.userid {$groupstudentsjoins->joins} "; $whereqa .= " AND {$groupstudentsjoins->wheres}"; $qaparams += $groupstudentsjoins->params; } $whichattemptsql = quiz_report_grade_method_sql($whichattempts); if ($whichattemptsql) { $whereqa .= ' AND ' . $whichattemptsql; } if (!$includeungraded) { $whereqa .= ' AND quiza.sumgrades IS NOT NULL'; } return [$fromqa, $whereqa, $qaparams]; } /** * Return a {@link qubaid_condition} from the values returned by {@link quiz_statistics_attempts_sql}. * * @param int $quizid * @param \core\dml\sql_join $groupstudentsjoins Contains joins, wheres, params * @param string $whichattempts which attempts to use, represented internally as one of the constants as used in * $quiz->grademethod ie. * QUIZ_GRADEAVERAGE, QUIZ_GRADEHIGHEST, QUIZ_ATTEMPTLAST or QUIZ_ATTEMPTFIRST * we calculate stats based on which attempts would affect the grade for each student. * @param bool $includeungraded * @return \qubaid_join */ function quiz_statistics_qubaids_condition($quizid, \core\dml\sql_join $groupstudentsjoins, $whichattempts = QUIZ_GRADEAVERAGE, $includeungraded = false) { list($fromqa, $whereqa, $qaparams) = quiz_statistics_attempts_sql( $quizid, $groupstudentsjoins, $whichattempts, $includeungraded); return new qubaid_join($fromqa, 'quiza.uniqueid', $whereqa, $qaparams); } PK9B\un.report/statistics/classes/privacy/provider.phpnu[. /** * Privacy Subsystem implementation for quiz_statistics. * * @package quiz_statistics * @copyright 2018 Andrew Nicols * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace quiz_statistics\privacy; defined('MOODLE_INTERNAL') || die(); /** * Privacy Subsystem for quiz_statistics 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'; } } PK9B\GH H ,report/statistics/classes/hook_callbacks.phpnu[. namespace quiz_statistics; use core\dml\sql_join; use mod_quiz\hook\attempt_state_changed; use mod_quiz\hook\structure_modified; use mod_quiz\quiz_attempt; use quiz_statistics\task\recalculate; /** * Hook callbacks * * @package quiz_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 hook_callbacks { /** * Clear the statistics cache for the quiz where the structure was modified. * * @param structure_modified $hook The structure_modified hook containing the new structure. * @return void */ public static function quiz_structure_modified(structure_modified $hook) { global $CFG; require_once($CFG->dirroot . '/mod/quiz/report/statistics/statisticslib.php'); require_once($CFG->dirroot . '/mod/quiz/report/statistics/report.php'); $quiz = $hook->get_structure()->get_quiz(); $qubaids = quiz_statistics_qubaids_condition( $quiz->id, new sql_join(), $quiz->grademethod ); $report = new \quiz_statistics_report(); $report->clear_cached_data($qubaids); } /** * Queue a statistics recalculation when an attempt is submitted or deleting. * * @param attempt_state_changed $hook * @return bool True if a task was queued. */ public static function quiz_attempt_submitted_or_deleted(attempt_state_changed $hook): bool { $originalattempt = $hook->get_original_attempt(); $updatedattempt = $hook->get_updated_attempt(); if (is_null($updatedattempt) || $updatedattempt->state === quiz_attempt::FINISHED) { // Only recalculate on deletion or submission. return recalculate::queue_future_run($originalattempt->quiz); } return false; } } PK9B\wk@"@"(report/statistics/classes/calculated.phpnu[. namespace quiz_statistics; defined('MOODLE_INTERNAL') || die(); /** * The statistics calculator returns an instance of this class which contains the calculated statistics. * * These quiz statistics calculations are described here : * * http://docs.moodle.org/dev/Quiz_statistics_calculations#Test_statistics * * @package quiz_statistics * @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 { /** * @param string $whichattempts which attempts to use, represented internally as one of the constants as used in * $quiz->grademethod ie. * QUIZ_GRADEAVERAGE, QUIZ_GRADEHIGHEST, QUIZ_ATTEMPTLAST or QUIZ_ATTEMPTFIRST * we calculate stats based on which attempts would affect the grade for each student, * the default null value is used when constructing an instance whose values will be * populated from a db record. */ public function __construct($whichattempts = null) { if ($whichattempts !== null) { $this->whichattempts = $whichattempts; } } /** * @var int which attempts we are calculating calculate stats from. */ public $whichattempts; /* Following stats all described here : http://docs.moodle.org/dev/Quiz_statistics_calculations#Test_statistics */ public $firstattemptscount = 0; public $allattemptscount = 0; public $lastattemptscount = 0; public $highestattemptscount = 0; public $firstattemptsavg; public $allattemptsavg; public $lastattemptsavg; public $highestattemptsavg; public $median; public $standarddeviation; public $skewness; public $kurtosis; public $cic; public $errorratio; public $standarderror; /** * @var int time these stats where calculated and cached. */ public $timemodified; /** * Count of attempts selected by $this->whichattempts * * @return int */ public function s() { return $this->get_field('count'); } /** * Average grade for the attempts selected by $this->whichattempts * * @return float */ public function avg() { return $this->get_field('avg'); } /** * Get the right field name to fetch a stat for these attempts that is calculated for more than one $whichattempts (count or * avg). * * @param string $field name of field * @return int|float */ protected function get_field($field) { $fieldname = calculator::using_attempts_string_id($this->whichattempts).$field; return $this->{$fieldname}; } /** * @param $course * @param $cm * @param $quiz * @return array to display in table or spreadsheet. */ public function get_formatted_quiz_info_data($course, $cm, $quiz) { // You can edit this array to control which statistics are displayed. $todisplay = ['firstattemptscount' => 'number', 'allattemptscount' => 'number', 'firstattemptsavg' => 'summarks_as_percentage', 'allattemptsavg' => 'summarks_as_percentage', 'lastattemptsavg' => 'summarks_as_percentage', 'highestattemptsavg' => 'summarks_as_percentage', 'median' => 'summarks_as_percentage', 'standarddeviation' => 'summarks_as_percentage', 'skewness' => 'number_format', 'kurtosis' => 'number_format', 'cic' => 'number_format_percent', 'errorratio' => 'number_format_percent', 'standarderror' => 'summarks_as_percentage']; // General information about the quiz. $quizinfo = []; $quizinfo[get_string('quizname', 'quiz_statistics')] = format_string($quiz->name); $quizinfo[get_string('coursename', 'quiz_statistics')] = format_string($course->fullname); if ($cm->idnumber) { $quizinfo[get_string('idnumbermod')] = $cm->idnumber; } if ($quiz->timeopen) { $quizinfo[get_string('quizopen', 'quiz')] = userdate($quiz->timeopen); } if ($quiz->timeclose) { $quizinfo[get_string('quizclose', 'quiz')] = userdate($quiz->timeclose); } if ($quiz->timeopen && $quiz->timeclose) { $quizinfo[get_string('duration', 'quiz_statistics')] = format_time($quiz->timeclose - $quiz->timeopen); } // The statistics. foreach ($todisplay as $property => $format) { if (!isset($this->$property) || !$format) { continue; } $value = $this->$property; switch ($format) { case 'summarks_as_percentage': $formattedvalue = quiz_report_scale_summarks_as_percentage($value, $quiz); break; case 'number_format_percent': $formattedvalue = quiz_format_grade($quiz, $value) . '%'; break; case 'number_format': // 2 extra decimal places, since not a percentage, // and we want the same number of sig figs. $formattedvalue = format_float($value, $quiz->decimalpoints + 2); break; case 'number': $formattedvalue = $value + 0; break; default: $formattedvalue = $value; } $quizinfo[get_string($property, 'quiz_statistics', calculator::using_attempts_lang_string($this->whichattempts))] = $formattedvalue; } return $quizinfo; } /** * @var array of names of properties of this class that are cached in db record. */ protected $fieldsindb = ['whichattempts', 'firstattemptscount', 'allattemptscount', 'firstattemptsavg', 'allattemptsavg', 'lastattemptscount', 'highestattemptscount', 'lastattemptsavg', 'highestattemptsavg', 'median', 'standarddeviation', 'skewness', 'kurtosis', 'cic', 'errorratio', 'standarderror']; /** * Cache the stats contained in this class. * * @param $qubaids \qubaid_condition */ public function cache($qubaids) { global $DB; $toinsert = new \stdClass(); foreach ($this->fieldsindb as $field) { $toinsert->{$field} = $this->{$field}; } $toinsert->hashcode = $qubaids->get_hash_code(); $toinsert->timemodified = time(); // Fix up some dodgy data. if (isset($toinsert->errorratio) && is_nan($toinsert->errorratio)) { $toinsert->errorratio = null; } if (isset($toinsert->standarderror) && is_nan($toinsert->standarderror)) { $toinsert->standarderror = null; } // Delete older statistics before we save the new ones. $transaction = $DB->start_delegated_transaction(); $DB->delete_records('quiz_statistics', ['hashcode' => $qubaids->get_hash_code()]); // Store the data. $DB->insert_record('quiz_statistics', $toinsert); $transaction->allow_commit(); } /** * Given a record from 'quiz_statistics' table load the data into the properties of this class. * * @param $record \stdClass from db. */ public function populate_from_record($record) { foreach ($this->fieldsindb as $field) { $this->$field = $record->$field; } $this->timemodified = $record->timemodified; } } PK9B\R>report/statistics/classes/event/observer/attempt_submitted.phpnu[. namespace quiz_statistics\event\observer; use core\check\performance\debugging; use quiz_statistics\task\recalculate; /** * Event observer for \mod_quiz\event\attempt_submitted * * @package quiz_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 * @deprecated Since Moodle 4.4 MDL-80099. * @todo Final deprecation in Moodle 4.8 MDL-80956. */ class attempt_submitted { /** * Queue an ad-hoc task to recalculate statistics for the quiz. * * This will defer running the task for 1 hour, to give other attempts in progress * a chance to submit. * * @param \mod_quiz\event\attempt_submitted $event * @return void * @deprecated Since Moodle 4.4 MDL-80099 */ public static function process(\mod_quiz\event\attempt_submitted $event): void { debugging('quiz_statistics\event\observer\attempt_submitted event observer has been deprecated in favour of ' . 'the quiz_statistics\hook_callbacks::quiz_attempt_submitted_or_deleted hook callback.', DEBUG_DEVELOPER); $data = $event->get_data(); recalculate::queue_future_run($data['other']['quizid']); } } PK9B\ <.report/statistics/classes/task/recalculate.phpnu[. namespace quiz_statistics\task; use core\dml\sql_join; use mod_quiz\quiz_attempt; use quiz_statistics_report; defined('MOODLE_INTERNAL') || die(); require_once($CFG->dirroot . '/mod/quiz/locallib.php'); require_once($CFG->dirroot . '/mod/quiz/report/statistics/statisticslib.php'); require_once($CFG->dirroot . '/mod/quiz/report/reportlib.php'); require_once($CFG->dirroot . '/mod/quiz/report/statistics/report.php'); /** * Re-calculate question statistics. * * @package quiz_statistics * @copyright 2022 Catalyst IT Australia Pty Ltd * @author Nathan Nguyen * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class recalculate extends \core\task\adhoc_task { /** * The time to delay queued runs by, to prevent repeated recalculations. */ const DELAY = HOURSECS; /** * Create a new instance of the task. * * This sets the properties so that only one task will be queued at a time for a given quiz. * * @param int $quizid * @return recalculate */ public static function instance(int $quizid): recalculate { $task = new self(); $task->set_component('quiz_statistics'); $task->set_custom_data((object)[ 'quizid' => $quizid, ]); return $task; } public function get_name(): string { return get_string('recalculatetask', 'quiz_statistics'); } public function execute(): void { global $DB; $dateformat = get_string('strftimedatetimeshortaccurate', 'core_langconfig'); $data = $this->get_custom_data(); $quiz = $DB->get_record('quiz', ['id' => $data->quizid]); if (!$quiz) { mtrace('Could not find quiz with ID ' . $data->quizid . '.'); return; } $course = $DB->get_record('course', ['id' => $quiz->course]); if (!$course) { mtrace('Could not find course with ID ' . $quiz->course . '.'); return; } $attemptcount = $DB->count_records('quiz_attempts', ['quiz' => $data->quizid, 'state' => quiz_attempt::FINISHED]); if ($attemptcount === 0) { mtrace('Could not find any finished attempts for course with ID ' . $data->quizid . '.'); return; } mtrace("Re-calculating statistics for quiz {$quiz->name} ({$quiz->id}) " . "from course {$course->shortname} ({$course->id}) with {$attemptcount} attempts, start time " . userdate(time(), $dateformat) . " ..."); $qubaids = quiz_statistics_qubaids_condition( $quiz->id, new sql_join(), $quiz->grademethod ); $report = new quiz_statistics_report(); $report->clear_cached_data($qubaids); $report->calculate_questions_stats_for_question_bank($quiz->id); mtrace(' Calculations completed at ' . userdate(time(), $dateformat) . '.'); } /** * Queue an instance of this task to happen after a delay. * * Multiple events may happen over a short period that require a recalculation. Rather than * run the recalculation each time, this will queue a single run of the task for a given quiz, * within the delay period. * * @param int $quizid The quiz to run the recalculation for. * @return bool true of the task was queued. */ public static function queue_future_run(int $quizid): bool { $task = self::instance($quizid); $task->set_next_run_time(time() + self::DELAY); return \core\task\manager::queue_adhoc_task($task, true); } } PK9B\0Ϫyy5report/statistics/classes/tests/statistics_helper.phpnu[. namespace quiz_statistics\tests; /** * Test helper functions for statistics * * @package quiz_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 statistics_helper { /** * Run any ad-hoc recalculation tasks that have been scheduled. * * We need a special function to do this as the tasks are deferred by one hour, * so we need to pass a custom $timestart argument. * * @param bool $discardoutput Capture and discard output from executed tasks? * @return void */ public static function run_pending_recalculation_tasks(bool $discardoutput = false): void { while ($task = \core\task\manager::get_next_adhoc_task( time() + HOURSECS + 1, false, '\quiz_statistics\task\recalculate' )) { if ($discardoutput) { ob_start(); } $task->execute(); if ($discardoutput) { ob_end_clean(); } \core\task\manager::adhoc_task_complete($task); } } } PK9B\Ǎ9report/statistics/classes/tests/statistics_test_trait.phpnu[. namespace quiz_statistics\tests; use quiz_statistics\task\recalculate; /** * Test methods for statistics recalculations * * @package quiz_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 */ trait statistics_test_trait { /** * Return a user, and a quiz with 2 questions. * * @return array [$user, $quiz, $course] */ protected function create_test_data(): array { $this->resetAfterTest(true); $generator = $this->getDataGenerator(); $user = $generator->create_user(); $course = $generator->create_course(); $quiz = $this->create_test_quiz($course); $this->add_two_regular_questions($generator->get_plugin_generator('core_question'), $quiz); return [$user, $quiz, $course]; } /** * Assert that a task is queued for a quiz. * * Check that the quizid stored in the task's custom data matches the provided quiz, * and that the run time is in one hour from when the test is being run (within a small margin of error). * * @param recalculate $task * @param \stdClass $quiz * @return void */ protected function assert_task_is_queued_for_quiz(recalculate $task, \stdClass $quiz): void { $data = $task->get_custom_data(); $this->assertEquals($quiz->id, $data->quizid); $this->assertEqualsWithDelta(time() + HOURSECS, $task->get_next_run_time(), 1); } } PK9B\"3?2report/statistics/classes/quiz_attempt_deleted.phpnu[. namespace quiz_statistics; use quiz_statistics\task\recalculate; /** * Queue a statistics recalculation when an attempt is deleted. * * @package quiz_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 * @deprecated Since Moodle 4.4 MDL-80099. * @todo Final deprecation in Moodle 4.8 MDL-80956. */ class quiz_attempt_deleted { /** * Queue a recalculation. * * @param int $quizid The quiz the attempt belongs to. * @return void * @deprecated Since Moodle 4.4 MDL-80099. */ public static function callback(int $quizid): void { debugging('quiz_statistics\quiz_attempt_deleted callback class has been deprecated in favour of ' . 'the quiz_statistics\hook_callbacks::quiz_attempt_submitted_or_deleted hook callback.', DEBUG_DEVELOPER); recalculate::queue_future_run($quizid); } } PK9B\MT00(report/statistics/classes/calculator.phpnu[. namespace quiz_statistics; defined('MOODLE_INTERNAL') || die(); /** * Class to calculate and also manage caching of quiz statistics. * * These quiz statistics calculations are described here : * * http://docs.moodle.org/dev/Quiz_statistics_calculations#Test_statistics * * @package quiz_statistics * @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 calculator { /** * @var \core\progress\base */ protected $progress; public function __construct(?\core\progress\base $progress = null) { if ($progress === null) { $progress = new \core\progress\none(); } $this->progress = $progress; } /** * Compute the quiz statistics. * * @param int $quizid the quiz id. * @param int $whichattempts which attempts to use, represented internally as one of the constants as used in * $quiz->grademethod ie. * QUIZ_GRADEAVERAGE, QUIZ_GRADEHIGHEST, QUIZ_ATTEMPTLAST or QUIZ_ATTEMPTFIRST * we calculate stats based on which attempts would affect the grade for each student. * @param \core\dml\sql_join $groupstudentsjoins Contains joins, wheres, params for students in this group. * @param int $p number of positions (slots). * @param float $sumofmarkvariance sum of mark variance, calculated as part of question statistics * @return calculated $quizstats The statistics for overall attempt scores. */ public function calculate($quizid, $whichattempts, \core\dml\sql_join $groupstudentsjoins, $p, $sumofmarkvariance) { $this->progress->start_progress('', 3); $quizstats = new calculated($whichattempts); $countsandaverages = $this->attempt_counts_and_averages($quizid, $groupstudentsjoins); $this->progress->progress(1); foreach ($countsandaverages as $propertyname => $value) { $quizstats->{$propertyname} = $value; } $s = $quizstats->s(); if ($s != 0) { // Recalculate sql again this time possibly including test for first attempt. list($fromqa, $whereqa, $qaparams) = quiz_statistics_attempts_sql($quizid, $groupstudentsjoins, $whichattempts); $quizstats->median = $this->median($s, $fromqa, $whereqa, $qaparams); $this->progress->progress(2); if ($s > 1) { $powers = $this->sum_of_powers_of_difference_to_mean($quizstats->avg(), $fromqa, $whereqa, $qaparams); $this->progress->progress(3); $quizstats->standarddeviation = sqrt($powers->power2 / ($s - 1)); // Skewness. if ($s > 2) { // See http://docs.moodle.org/dev/Quiz_item_analysis_calculations_in_practise#Skewness_and_Kurtosis. $m2 = $powers->power2 / $s; $m3 = $powers->power3 / $s; $m4 = $powers->power4 / $s; $k2 = $s * $m2 / ($s - 1); $k3 = $s * $s * $m3 / (($s - 1) * ($s - 2)); if ($k2 != 0) { $quizstats->skewness = $k3 / (pow($k2, 3 / 2)); // Kurtosis. if ($s > 3) { $k4 = $s * $s * ((($s + 1) * $m4) - (3 * ($s - 1) * $m2 * $m2)) / (($s - 1) * ($s - 2) * ($s - 3)); $quizstats->kurtosis = $k4 / ($k2 * $k2); } if ($p > 1) { $quizstats->cic = (100 * $p / ($p - 1)) * (1 - ($sumofmarkvariance / $k2)); $quizstats->errorratio = 100 * sqrt(1 - ($quizstats->cic / 100)); $quizstats->standarderror = $quizstats->errorratio * $quizstats->standarddeviation / 100; } } } } $quizstats->cache(quiz_statistics_qubaids_condition($quizid, $groupstudentsjoins, $whichattempts)); } $this->progress->end_progress(); return $quizstats; } /** * @var int previously, the time after which statistics are automatically recomputed. * @deprecated since Moodle 4.3. Use of pre-computed stats is no longer time-limited. * @todo MDL-78091 Final deprecation in Moodle 4.7 */ const TIME_TO_CACHE = 900; // 15 minutes. /** * Load cached statistics from the database. * * @param \qubaid_condition $qubaids * @return calculated|false The statistics for overall attempt scores or false if not cached. */ public function get_cached($qubaids) { global $DB; $lastcalculatedtime = $this->get_last_calculated_time($qubaids); if (!$lastcalculatedtime) { return false; } $fromdb = $DB->get_record('quiz_statistics', ['hashcode' => $qubaids->get_hash_code(), 'timemodified' => $lastcalculatedtime]); $stats = new calculated(); $stats->populate_from_record($fromdb); return $stats; } /** * Find time of non-expired statistics in the database. * * @param $qubaids \qubaid_condition * @return int|bool Time of cached record that matches this qubaid_condition or false is non found. */ public function get_last_calculated_time($qubaids) { global $DB; $lastcalculatedtime = $DB->get_field('quiz_statistics', 'COALESCE(MAX(timemodified), 0)', ['hashcode' => $qubaids->get_hash_code()]); if ($lastcalculatedtime) { return $lastcalculatedtime; } else { return false; } } /** * Given a particular quiz grading method return a lang string describing which attempts contribute to grade. * * Note internally we use the grading method constants to represent which attempts we are calculating statistics for, each * grading method corresponds to different attempts for each user. * * @param int $whichattempts which attempts to use, represented internally as one of the constants as used in * $quiz->grademethod ie. * QUIZ_GRADEAVERAGE, QUIZ_GRADEHIGHEST, QUIZ_ATTEMPTLAST or QUIZ_ATTEMPTFIRST * we calculate stats based on which attempts would affect the grade for each student. * @return string the appropriate lang string to describe this option. */ public static function using_attempts_lang_string($whichattempts) { return get_string(static::using_attempts_string_id($whichattempts), 'quiz_statistics'); } /** * Given a particular quiz grading method return a string id for use as a field name prefix in mdl_quiz_statistics or to * fetch the appropriate language string describing which attempts contribute to grade. * * Note internally we use the grading method constants to represent which attempts we are calculating statistics for, each * grading method corresponds to different attempts for each user. * * @param int $whichattempts which attempts to use, represented internally as one of the constants as used in * $quiz->grademethod ie. * QUIZ_GRADEAVERAGE, QUIZ_GRADEHIGHEST, QUIZ_ATTEMPTLAST or QUIZ_ATTEMPTFIRST * we calculate stats based on which attempts would affect the grade for each student. * @return string the string id for this option. */ public static function using_attempts_string_id($whichattempts) { switch ($whichattempts) { case QUIZ_ATTEMPTFIRST : return 'firstattempts'; case QUIZ_GRADEHIGHEST : return 'highestattempts'; case QUIZ_ATTEMPTLAST : return 'lastattempts'; case QUIZ_GRADEAVERAGE : return 'allattempts'; } } /** * Calculating count and mean of marks for first and ALL attempts by students. * * See : http://docs.moodle.org/dev/Quiz_item_analysis_calculations_in_practise * #Calculating_MEAN_of_grades_for_all_attempts_by_students * @param int $quizid * @param \core\dml\sql_join $groupstudentsjoins Contains joins, wheres, params for students in this group. * @return \stdClass with properties with count and avg with prefixes firstattempts, highestattempts, etc. */ protected function attempt_counts_and_averages($quizid, \core\dml\sql_join $groupstudentsjoins) { global $DB; $attempttotals = new \stdClass(); foreach (array_keys(quiz_get_grading_options()) as $which) { list($fromqa, $whereqa, $qaparams) = quiz_statistics_attempts_sql($quizid, $groupstudentsjoins, $which); $fromdb = $DB->get_record_sql("SELECT COUNT(*) AS rcount, AVG(sumgrades) AS average FROM $fromqa WHERE $whereqa", $qaparams); $fieldprefix = static::using_attempts_string_id($which); $attempttotals->{$fieldprefix.'avg'} = $fromdb->average; $attempttotals->{$fieldprefix.'count'} = $fromdb->rcount; } return $attempttotals; } /** * Median mark. * * http://docs.moodle.org/dev/Quiz_statistics_calculations#Median_Score * * @param $s integer count of attempts * @param $fromqa string * @param $whereqa string * @param $qaparams string * @return float */ protected function median($s, $fromqa, $whereqa, $qaparams) { global $DB; if ($s % 2 == 0) { // An even number of attempts. $limitoffset = $s / 2 - 1; $limit = 2; } else { $limitoffset = floor($s / 2); $limit = 1; } $sql = "SELECT quiza.id, quiza.sumgrades FROM $fromqa WHERE $whereqa ORDER BY sumgrades"; $medianmarks = $DB->get_records_sql_menu($sql, $qaparams, $limitoffset, $limit); return array_sum($medianmarks) / count($medianmarks); } /** * Fetch the sum of squared, cubed and to the power 4 differences between sumgrade and it's mean. * * Explanation here : http://docs.moodle.org/dev/Quiz_item_analysis_calculations_in_practise * #Calculating_Standard_Deviation.2C_Skewness_and_Kurtosis_of_grades_for_all_attempts_by_students * * @param $mean * @param $fromqa * @param $whereqa * @param $qaparams * @return stdClass with properties power2, power3, power4 */ protected function sum_of_powers_of_difference_to_mean($mean, $fromqa, $whereqa, $qaparams) { global $DB; $sql = "SELECT SUM(POWER((quiza.sumgrades - $mean), 2)) AS power2, SUM(POWER((quiza.sumgrades - $mean), 3)) AS power3, SUM(POWER((quiza.sumgrades - $mean), 4)) AS power4 FROM $fromqa WHERE $whereqa"; $params = ['mean1' => $mean, 'mean2' => $mean, 'mean3' => $mean] + $qaparams; return $DB->get_record_sql($sql, $params, MUST_EXIST); } } PK9B\Á/report/statistics/statistics_question_table.phpnu[. /** * Quiz statistics report, table for showing response analysis for a particular question (or sub question). * * @package quiz_statistics * @copyright 2014 Open University * @author James Pratt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); require_once($CFG->libdir . '/tablelib.php'); /** * This table shows statistics about a particular question. * * Lists the responses that students made to this question, with frequency counts. * * The responses may be grouped, either by sub-part of the question, or by the * answer they match. * * @copyright 2014 Open University * @author James Pratt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class quiz_statistics_question_table extends flexible_table { /** @var stdClass full question object for this question. */ protected $questiondata; /** @var int no of attempts. */ protected $s; /** * Constructor. * * @param int $qid the id of the particular question whose statistics are being * displayed. */ public function __construct($qid) { parent::__construct('mod-quiz-report-statistics-question-table' . $qid); } /** * Set up columns and column names and other table settings. * * @param moodle_url $reporturl * @param stdClass $questiondata * @param integer $s number of attempts on this question. * @param \core_question\statistics\responses\analysis_for_question $responseanalysis */ public function question_setup($reporturl, $questiondata, $s, $responseanalysis) { $this->questiondata = $questiondata; $this->s = $s; $this->define_baseurl($reporturl->out()); $this->collapsible(false); $this->set_attribute('class', 'generaltable generalbox boxaligncenter quizresponseanalysis'); // Define the table columns. $columns = []; $headers = []; if ($responseanalysis->has_subparts()) { $columns[] = 'part'; $headers[] = get_string('partofquestion', 'quiz_statistics'); } if ($responseanalysis->has_multiple_response_classes()) { $columns[] = 'responseclass'; $headers[] = get_string('modelresponse', 'quiz_statistics'); if ($responseanalysis->has_actual_responses()) { $columns[] = 'response'; $headers[] = get_string('actualresponse', 'quiz_statistics'); } } else { $columns[] = 'response'; $headers[] = get_string('response', 'quiz_statistics'); } $columns[] = 'fraction'; $headers[] = get_string('optiongrade', 'quiz_statistics'); if (!$responseanalysis->has_multiple_tries_data()) { $columns[] = 'totalcount'; $headers[] = get_string('count', 'quiz_statistics'); } else { $countcolumns = range(1, $responseanalysis->get_maximum_tries()); foreach ($countcolumns as $countcolumn) { $columns[] = 'trycount'.$countcolumn; $headers[] = get_string('counttryno', 'quiz_statistics', $countcolumn); } } $columns[] = 'frequency'; $headers[] = get_string('frequency', 'quiz_statistics'); $this->define_columns($columns); $this->define_headers($headers); $this->sortable(false); $this->column_class('fraction', 'numcol'); $this->column_class('count', 'numcol'); $this->column_class('frequency', 'numcol'); $this->column_suppress('part'); $this->column_suppress('responseclass'); parent::setup(); } /** * Take a float where 1 represents 100% and return a string representing the percentage. * * @param float $fraction The fraction. * @return string The fraction as a percentage. */ protected function format_percentage($fraction) { return format_float($fraction * 100, 2) . '%'; } /** * The mark fraction that this response earns. * @param stdClass $response containst the data to display. * @return string contents of this table cell. */ protected function col_fraction($response) { if (is_null($response->fraction)) { return ''; } return $this->format_percentage($response->fraction); } /** * The frequency with which this response was given. * @param stdClass $response contains the data to display. * @return string contents of this table cell. */ protected function col_frequency($response) { if (!$this->s) { return ''; } return $this->format_percentage($response->totalcount / $this->s); } /** * If there is not a col_{column name} method then we call this method. If it returns null * that means just output the property as in the table raw data. If this returns none null * then this is the output for this cell of the table. * * @param string $colname The name of this column. * @param stdClass $response The raw data for this row. * @return string|null The value for this cell of the table or null means use raw data. */ public function other_cols($colname, $response) { if (preg_match('/^trycount(\d+)$/', $colname, $matches)) { if (isset($response->trycount[$matches[1]])) { return $response->trycount[$matches[1]]; } else { return 0; } } else if ($colname == 'part' || $colname == 'responseclass' || $colname == 'response') { return s($response->$colname); } else { return null; } } } PK9B\~ttreport/statistics/upgrade.txtnu[=== 4.5 Onwards === This file has been replaced by UPGRADING.md. See MDL-81125 for further information. === This files describes API changes in /mod/quiz/report/statistics/*, information provided here is intended especially for developers. === 4.3 === * The methods quiz_statistics_report::calculate_questions_stats_for_question_bank and get_all_stats_and_analysis (which are really private to the quiz, and not part of any API you should be using) now have a new optional argument $calculateifrequired. * In the past, the methods \quiz_statistics\calculator::get_last_calculated_time() and calculator::get_cached() only returned the pre-computed statistics if they were computed less than 15 minutes ago. Now, they will always return any computed statistics that exist. Therefore, the constant calculator::TIME_TO_CACHE has been deprecated. === 3.2 === * The function quiz_statistics_graph_get_new_colour() is deprecated in favour of the funtionality present in the new charting library. * The function quiz_statistics_renumber_placeholders() is removed as the changes in MDL-31243 and MDL-27072 make this redundant. PK9B\ehееreport/statistics/report.phpnu[. /** * Quiz statistics report class. * * @package quiz_statistics * @copyright 2014 Open University * @author James Pratt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); use core_question\statistics\responses\analyser; use mod_quiz\local\reports\report_base; use core_question\statistics\questions\all_calculated_for_qubaid_condition; require_once($CFG->dirroot . '/mod/quiz/report/reportlib.php'); require_once($CFG->dirroot . '/mod/quiz/report/statistics/statistics_form.php'); require_once($CFG->dirroot . '/mod/quiz/report/statistics/statistics_table.php'); require_once($CFG->dirroot . '/mod/quiz/report/statistics/statistics_question_table.php'); require_once($CFG->dirroot . '/mod/quiz/report/statistics/statisticslib.php'); /** * The quiz statistics report provides summary information about each question in * a quiz, compared to the whole quiz. It also provides a drill-down to more * detailed information about each question. * * @copyright 2008 Jamie Pratt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class quiz_statistics_report extends report_base { /** @var context_module context of this quiz.*/ protected $context; /** @var quiz_statistics_table instance of table class used for main questions stats table. */ protected $table; /** @var \core\progress\base|null $progress Handles progress reporting or not. */ protected $progress = null; /** * Display the report. */ public function display($quiz, $cm, $course) { global $OUTPUT, $DB; raise_memory_limit(MEMORY_HUGE); $this->context = context_module::instance($cm->id); if (!quiz_has_questions($quiz->id)) { $this->print_header_and_tabs($cm, $course, $quiz, 'statistics'); echo quiz_no_questions_message($quiz, $cm, $this->context); return true; } // Work out the display options. $download = optional_param('download', '', PARAM_ALPHA); $everything = optional_param('everything', 0, PARAM_BOOL); $recalculate = optional_param('recalculate', 0, PARAM_BOOL); // A qid paramter indicates we should display the detailed analysis of a sub question. $qid = optional_param('qid', 0, PARAM_INT); $slot = optional_param('slot', 0, PARAM_INT); $variantno = optional_param('variant', null, PARAM_INT); $whichattempts = optional_param('whichattempts', $quiz->grademethod, PARAM_INT); $whichtries = optional_param('whichtries', question_attempt::LAST_TRY, PARAM_ALPHA); $pageoptions = []; $pageoptions['id'] = $cm->id; $pageoptions['mode'] = 'statistics'; $reporturl = new moodle_url('/mod/quiz/report.php', $pageoptions); $mform = new quiz_statistics_settings_form($reporturl, compact('quiz')); $mform->set_data(['whichattempts' => $whichattempts, 'whichtries' => $whichtries]); if ($whichattempts != $quiz->grademethod) { $reporturl->param('whichattempts', $whichattempts); } if ($whichtries != question_attempt::LAST_TRY) { $reporturl->param('whichtries', $whichtries); } // Find out current groups mode. $currentgroup = $this->get_current_group($cm, $course, $this->context); $nostudentsingroup = false; // True if a group is selected and there is no one in it. if (empty($currentgroup)) { $currentgroup = 0; $groupstudentsjoins = new \core\dml\sql_join(); } else if ($currentgroup == self::NO_GROUPS_ALLOWED) { $groupstudentsjoins = new \core\dml\sql_join(); $nostudentsingroup = true; } else { // All users who can attempt quizzes and who are in the currently selected group. $groupstudentsjoins = get_enrolled_with_capabilities_join($this->context, '', ['mod/quiz:reviewmyattempts', 'mod/quiz:attempt'], $currentgroup); if (!empty($groupstudentsjoins->joins)) { $sql = "SELECT DISTINCT u.id FROM {user} u {$groupstudentsjoins->joins} WHERE {$groupstudentsjoins->wheres}"; if (!$DB->record_exists_sql($sql, $groupstudentsjoins->params)) { $nostudentsingroup = true; } } } $qubaids = quiz_statistics_qubaids_condition($quiz->id, $groupstudentsjoins, $whichattempts); // If recalculate was requested, handle that. if ($recalculate && confirm_sesskey()) { $this->clear_cached_data($qubaids); redirect($reporturl); } // Set up the main table. $this->table = new quiz_statistics_table(); if ($everything) { $report = get_string('completestatsfilename', 'quiz_statistics'); } else { $report = get_string('questionstatsfilename', 'quiz_statistics'); } $courseshortname = format_string($course->shortname, true, ['context' => context_course::instance($course->id)]); $filename = quiz_report_download_filename($report, $courseshortname, $quiz->name); $this->table->is_downloading($download, $filename, get_string('quizstructureanalysis', 'quiz_statistics')); $questions = $this->load_and_initialise_questions_for_calculations($quiz); // Print the page header stuff (if not downloading. if (!$this->table->is_downloading()) { $this->print_header_and_tabs($cm, $course, $quiz, 'statistics'); } if (!$nostudentsingroup) { // Get the data to be displayed. $progress = $this->get_progress_trace_instance(); list($quizstats, $questionstats) = $this->get_all_stats_and_analysis($quiz, $whichattempts, $whichtries, $groupstudentsjoins, $questions, $progress); if (is_null($quizstats)) { echo $OUTPUT->notification(get_string('nostats', 'quiz_statistics'), 'error'); return true; } } else { // Or create empty stats containers. $quizstats = new \quiz_statistics\calculated($whichattempts); $questionstats = new \core_question\statistics\questions\all_calculated_for_qubaid_condition(); } // Set up the table. $this->table->statistics_setup($quiz, $cm->id, $reporturl, $quizstats->s()); // Print the rest of the page header stuff (if not downloading. if (!$this->table->is_downloading()) { if (groups_get_activity_groupmode($cm)) { groups_print_activity_menu($cm, $reporturl->out()); if ($currentgroup && $nostudentsingroup) { $OUTPUT->notification(get_string('nostudentsingroup', 'quiz_statistics')); } } if (!$this->table->is_downloading() && $quizstats->s() == 0) { echo $OUTPUT->notification(get_string('nogradedattempts', 'quiz_statistics')); } foreach ($questionstats->any_error_messages() as $errormessage) { echo $OUTPUT->notification($errormessage); } // Print display options form. $mform->display(); } if ($everything) { // Implies is downloading. // Overall report, then the analysis of each question. $quizinfo = $quizstats->get_formatted_quiz_info_data($course, $cm, $quiz); $this->download_quiz_info_table($quizinfo); if ($quizstats->s()) { $this->output_quiz_structure_analysis_table($questionstats); if ($this->table->is_downloading() == 'html' && $quizstats->s() != 0) { $this->output_statistics_graph($quiz, $qubaids); } $this->output_all_question_response_analysis($qubaids, $questions, $questionstats, $reporturl, $whichtries); } $this->table->export_class_instance()->finish_document(); } else if ($qid) { // Report on an individual sub-question indexed questionid. if (!$questionstats->has_subq($qid, $variantno)) { throw new \moodle_exception('questiondoesnotexist', 'question'); } $this->output_individual_question_data($quiz, $questionstats->for_subq($qid, $variantno)); $this->output_individual_question_response_analysis($questionstats->for_subq($qid, $variantno)->question, $variantno, $questionstats->for_subq($qid, $variantno)->s, $reporturl, $qubaids, $whichtries); // Back to overview link. echo $OUTPUT->box('' . get_string('backtoquizreport', 'quiz_statistics') . '', 'boxaligncenter generalbox boxwidthnormal mdl-align'); } else if ($slot) { // Report on an individual question indexed by position. if (!isset($questions[$slot])) { throw new \moodle_exception('questiondoesnotexist', 'question'); } if ($variantno === null && ($questionstats->for_slot($slot)->get_sub_question_ids() || $questionstats->for_slot($slot)->get_variants())) { if (!$this->table->is_downloading()) { $number = $questionstats->for_slot($slot)->question->number; echo $OUTPUT->heading(get_string('slotstructureanalysis', 'quiz_statistics', $number), 3); } $this->table->define_baseurl(new moodle_url($reporturl, ['slot' => $slot])); $this->table->format_and_add_array_of_rows($questionstats->structure_analysis_for_one_slot($slot)); } else { $this->output_individual_question_data($quiz, $questionstats->for_slot($slot, $variantno)); $this->output_individual_question_response_analysis($questions[$slot], $variantno, $questionstats->for_slot($slot, $variantno)->s, $reporturl, $qubaids, $whichtries); } if (!$this->table->is_downloading()) { // Back to overview link. echo $OUTPUT->box('' . get_string('backtoquizreport', 'quiz_statistics') . '', 'backtomainstats boxaligncenter generalbox boxwidthnormal mdl-align'); } else { $this->table->finish_output(); } } else if ($this->table->is_downloading()) { // Downloading overview report. $quizinfo = $quizstats->get_formatted_quiz_info_data($course, $cm, $quiz); $this->download_quiz_info_table($quizinfo); if ($quizstats->s()) { $this->output_quiz_structure_analysis_table($questionstats); } $this->table->export_class_instance()->finish_document(); } else { // On-screen display of overview report. echo $OUTPUT->heading(get_string('quizinformation', 'quiz_statistics'), 3); echo $this->output_caching_info($quizstats->timemodified, $quiz->id, $groupstudentsjoins, $whichattempts, $reporturl); echo $this->everything_download_options($reporturl); $quizinfo = $quizstats->get_formatted_quiz_info_data($course, $cm, $quiz); echo $this->output_quiz_info_table($quizinfo); if ($quizstats->s()) { echo $OUTPUT->heading(get_string('quizstructureanalysis', 'quiz_statistics'), 3); $this->output_quiz_structure_analysis_table($questionstats); $this->output_statistics_graph($quiz, $qubaids); } } return true; } /** * Display the statistical and introductory information about a question. * Only called when not downloading. * * @param stdClass $quiz the quiz settings. * @param \core_question\statistics\questions\calculated $questionstat the question to report on. */ protected function output_individual_question_data($quiz, $questionstat) { global $OUTPUT; // On-screen display. Show a summary of the question's place in the quiz, // and the question statistics. $datumfromtable = $this->table->format_row($questionstat); // Set up the question info table. $questioninfotable = new html_table(); $questioninfotable->align = ['center', 'center']; $questioninfotable->width = '60%'; $questioninfotable->attributes['class'] = 'generaltable titlesleft table table-hover'; $questioninfotable->data = []; $questioninfotable->data[] = [get_string('modulename', 'quiz'), $quiz->name]; $questioninfotable->data[] = [get_string('questionname', 'quiz_statistics'), $questionstat->question->name.' '.$datumfromtable['actions']]; if ($questionstat->variant !== null) { $questioninfotable->data[] = [get_string('variant', 'quiz_statistics'), $questionstat->variant]; } $questioninfotable->data[] = [get_string('questiontype', 'quiz_statistics'), $datumfromtable['icon'] . ' ' . question_bank::get_qtype($questionstat->question->qtype, false)->menu_name() . ' ' . $datumfromtable['icon']]; $questioninfotable->data[] = [get_string('positions', 'quiz_statistics'), $questionstat->positions]; // Set up the question statistics table. $questionstatstable = new html_table(); $questionstatstable->align = ['center', 'center']; $questionstatstable->width = '60%'; $questionstatstable->attributes['class'] = 'generaltable titlesleft table table-hover'; unset($datumfromtable['number']); unset($datumfromtable['icon']); $actions = $datumfromtable['actions']; unset($datumfromtable['actions']); unset($datumfromtable['name']); $labels = [ 's' => get_string('attempts', 'quiz_statistics'), 'facility' => get_string('facility', 'quiz_statistics'), 'sd' => get_string('standarddeviationq', 'quiz_statistics'), 'random_guess_score' => get_string('random_guess_score', 'quiz_statistics'), 'intended_weight' => get_string('intended_weight', 'quiz_statistics'), 'effective_weight' => get_string('effective_weight', 'quiz_statistics'), 'discrimination_index' => get_string('discrimination_index', 'quiz_statistics'), 'discriminative_efficiency' => get_string('discriminative_efficiency', 'quiz_statistics') ]; foreach ($datumfromtable as $item => $value) { $questionstatstable->data[] = [$labels[$item], $value]; } // Display the various bits. echo $OUTPUT->heading(get_string('questioninformation', 'quiz_statistics'), 3); echo html_writer::table($questioninfotable); echo $this->render_question_text($questionstat->question); echo $OUTPUT->heading(get_string('questionstatistics', 'quiz_statistics'), 3); echo html_writer::table($questionstatstable); } /** * Output question text in a box with urls appropriate for a preview of the question. * * @param stdClass $question question data. * @return string HTML of question text, ready for display. */ protected function render_question_text($question) { global $OUTPUT; $text = question_rewrite_question_preview_urls($question->questiontext, $question->id, $question->contextid, 'question', 'questiontext', $question->id, $this->context->id, 'quiz_statistics'); return $OUTPUT->box(format_text($text, $question->questiontextformat, ['noclean' => true, 'para' => false, 'overflowdiv' => true]), 'questiontext boxaligncenter generalbox boxwidthnormal mdl-align'); } /** * Display the response analysis for a question. * * @param stdClass $question the question to report on. * @param int|null $variantno the variant * @param int $s * @param moodle_url $reporturl the URL to redisplay this report. * @param qubaid_condition $qubaids * @param string $whichtries */ protected function output_individual_question_response_analysis($question, $variantno, $s, $reporturl, $qubaids, $whichtries = question_attempt::LAST_TRY) { global $OUTPUT; if (!question_bank::get_qtype($question->qtype, false)->can_analyse_responses()) { return; } $qtable = new quiz_statistics_question_table($question->id); $exportclass = $this->table->export_class_instance(); $qtable->export_class_instance($exportclass); if (!$this->table->is_downloading()) { // Output an appropriate title. echo $OUTPUT->heading(get_string('analysisofresponses', 'quiz_statistics'), 3); } else { // Work out an appropriate title. $a = clone($question); $a->variant = $variantno; if (!empty($question->number) && !is_null($variantno)) { $questiontabletitle = get_string('analysisnovariant', 'quiz_statistics', $a); } else if (!empty($question->number)) { $questiontabletitle = get_string('analysisno', 'quiz_statistics', $a); } else if (!is_null($variantno)) { $questiontabletitle = get_string('analysisvariant', 'quiz_statistics', $a); } else { $questiontabletitle = get_string('analysisnameonly', 'quiz_statistics', $a); } if ($this->table->is_downloading() == 'html') { $questiontabletitle = get_string('analysisofresponsesfor', 'quiz_statistics', $questiontabletitle); } // Set up the table. $exportclass->start_table($questiontabletitle); if ($this->table->is_downloading() == 'html') { echo $this->render_question_text($question); } } $responesanalyser = new analyser($question, $whichtries); $responseanalysis = $responesanalyser->load_cached($qubaids, $whichtries); $qtable->question_setup($reporturl, $question, $s, $responseanalysis); if ($this->table->is_downloading()) { $exportclass->output_headers($qtable->headers); } // Where no variant no is specified the variant no is actually one. if ($variantno === null) { $variantno = 1; } foreach ($responseanalysis->get_subpart_ids($variantno) as $partid) { $subpart = $responseanalysis->get_analysis_for_subpart($variantno, $partid); foreach ($subpart->get_response_class_ids() as $responseclassid) { $responseclass = $subpart->get_response_class($responseclassid); $tabledata = $responseclass->data_for_question_response_table($subpart->has_multiple_response_classes(), $partid); foreach ($tabledata as $row) { $qtable->add_data_keyed($qtable->format_row($row)); } } } $qtable->finish_output(!$this->table->is_downloading()); } /** * Output the table that lists all the questions in the quiz with their statistics. * * @param \core_question\statistics\questions\all_calculated_for_qubaid_condition $questionstats the stats for all questions in * the quiz including subqs and * variants. */ protected function output_quiz_structure_analysis_table($questionstats) { $limitvariants = !$this->table->is_downloading(); foreach ($questionstats->get_all_slots() as $slot) { // Output the data for these question statistics. $structureanalysis = $questionstats->structure_analysis_for_one_slot($slot, $limitvariants); if (is_null($structureanalysis)) { $this->table->add_separator(); } else { foreach ($structureanalysis as $row) { $bgcssclass = ''; // The only way to identify in this point of the report if a row is a summary row // is checking if it's a instance of calculated_question_summary class. if ($row instanceof \core_question\statistics\questions\calculated_question_summary) { // Apply a custom css class to summary row to remove border and reduce paddings. $bgcssclass = 'quiz_statistics-summaryrow'; // For question that contain a summary row, we add a "hidden" row in between so the report // display both rows with same background color. $this->table->add_data_keyed([], 'd-none hidden'); } $this->table->add_data_keyed($this->table->format_row($row), $bgcssclass); } } } $this->table->finish_output(!$this->table->is_downloading()); } /** * Return HTML for table of overall quiz statistics. * * @param array $quizinfo as returned by {@link get_formatted_quiz_info_data()}. * @return string the HTML. */ protected function output_quiz_info_table($quizinfo) { $quizinfotable = new html_table(); $quizinfotable->align = ['center', 'center']; $quizinfotable->width = '60%'; $quizinfotable->attributes['class'] = 'generaltable titlesleft table table-hover'; $quizinfotable->data = []; foreach ($quizinfo as $heading => $value) { $quizinfotable->data[] = [$heading, $value]; } return html_writer::table($quizinfotable); } /** * Download the table of overall quiz statistics. * * @param array $quizinfo as returned by {@link get_formatted_quiz_info_data()}. */ protected function download_quiz_info_table($quizinfo) { global $OUTPUT; // HTML download is a special case. if ($this->table->is_downloading() == 'html') { echo $OUTPUT->heading(get_string('quizinformation', 'quiz_statistics'), 3); echo $this->output_quiz_info_table($quizinfo); return; } // Reformat the data ready for output. $headers = []; $row = []; foreach ($quizinfo as $heading => $value) { $headers[] = $heading; $row[] = $value; } // Do the output. $exportclass = $this->table->export_class_instance(); $exportclass->start_table(get_string('quizinformation', 'quiz_statistics')); $exportclass->output_headers($headers); $exportclass->add_data($row); $exportclass->finish_table(); } /** * Output the HTML needed to show the statistics graph. * * @param stdClass $quiz the quiz. * @param qubaid_condition $qubaids the question usages whose responses to analyse. */ protected function output_statistics_graph($quiz, $qubaids) { global $DB, $PAGE; // Load the rest of the required data. $questions = quiz_report_get_significant_questions($quiz); // Only load main question not sub questions. $questionstatistics = $DB->get_records_select('question_statistics', 'hashcode = ? AND slot IS NOT NULL AND variant IS NULL', [$qubaids->get_hash_code()]); // Configure what to display. $fieldstoplot = [ 'facility' => get_string('facility', 'quiz_statistics'), 'discriminativeefficiency' => get_string('discriminative_efficiency', 'quiz_statistics') ]; $fieldstoplotfactor = ['facility' => 100, 'discriminativeefficiency' => 1]; // Prepare the arrays to hold the data. $xdata = []; foreach (array_keys($fieldstoplot) as $fieldtoplot) { $ydata[$fieldtoplot] = []; } // Fill in the data for each question. foreach ($questionstatistics as $questionstatistic) { $number = $questions[$questionstatistic->slot]->number; $xdata[$number] = $number; foreach ($fieldstoplot as $fieldtoplot => $notused) { $value = $questionstatistic->$fieldtoplot; if (is_null($value)) { $value = 0; } $value *= $fieldstoplotfactor[$fieldtoplot]; $ydata[$fieldtoplot][$number] = number_format($value, 2); } } // Create the chart. sort($xdata); $chart = new \core\chart_bar(); $chart->get_xaxis(0, true)->set_label(get_string('position', 'quiz_statistics')); $chart->set_labels(array_values($xdata)); foreach ($fieldstoplot as $fieldtoplot => $notused) { ksort($ydata[$fieldtoplot]); $series = new \core\chart_series($fieldstoplot[$fieldtoplot], array_values($ydata[$fieldtoplot])); $chart->add_series($series); } // Find max. $max = 0; foreach ($fieldstoplot as $fieldtoplot => $notused) { $max = max($max, max($ydata[$fieldtoplot])); } // Set Y properties. $yaxis = $chart->get_yaxis(0, true); $yaxis->set_stepsize(10); $yaxis->set_label('%'); $output = $PAGE->get_renderer('mod_quiz'); $graphname = get_string('statisticsreportgraph', 'quiz_statistics'); echo $output->chart($chart, $graphname); } /** * Get the quiz and question statistics, either by loading the cached results, * or by recomputing them. * * @param stdClass $quiz the quiz settings. * @param string $whichattempts which attempts to use, represented internally as one of the constants as used in * $quiz->grademethod ie. * QUIZ_GRADEAVERAGE, QUIZ_GRADEHIGHEST, QUIZ_ATTEMPTLAST or QUIZ_ATTEMPTFIRST * we calculate stats based on which attempts would affect the grade for each student. * @param string $whichtries which tries to analyse for response analysis. Will be one of * question_attempt::FIRST_TRY, LAST_TRY or ALL_TRIES. * @param \core\dml\sql_join $groupstudentsjoins Contains joins, wheres, params for students in this group. * @param array $questions full question data. * @param \core\progress\base|null $progress * @param bool $calculateifrequired if true (the default) the stats will be calculated if not already stored. * If false, [null, null] will be returned if the stats are not already available. * @param bool $performanalysis if true (the default) and there are calculated stats, analysis will be performed * for each question. * @return array with 2 elements: - $quizstats The statistics for overall attempt scores. * - $questionstats \core_question\statistics\questions\all_calculated_for_qubaid_condition * Both may be null, if $calculateifrequired is false. */ public function get_all_stats_and_analysis( $quiz, $whichattempts, $whichtries, \core\dml\sql_join $groupstudentsjoins, $questions, $progress = null, bool $calculateifrequired = true, bool $performanalysis = true) { if ($progress === null) { $progress = new \core\progress\none(); } $qubaids = quiz_statistics_qubaids_condition($quiz->id, $groupstudentsjoins, $whichattempts); $qcalc = new \core_question\statistics\questions\calculator($questions, $progress); $quizcalc = new \quiz_statistics\calculator($progress); $progress->start_progress('', 4); // Get a lock on this set of qubaids before performing calculations. This prevents the same calculation running // concurrently and causing database deadlocks. We use a long timeout here as a big quiz with lots of attempts may // take a long time to process. $lockfactory = \core\lock\lock_config::get_lock_factory('quiz_statistics_get_stats'); $lock = $lockfactory->get_lock($qubaids->get_hash_code(), 0); if (!$lock) { if (!$calculateifrequired) { // We're not going to do the calculation in this request anyway, so just give up here. $progress->progress(4); $progress->end_progress(); return [null, null]; } $locktimeout = get_config('quiz_statistics', 'getstatslocktimeout'); $lock = \core\lock\lock_utils::wait_for_lock_with_progress( $lockfactory, $qubaids->get_hash_code(), $progress, $locktimeout, get_string('getstatslockprogress', 'quiz_statistics'), ); if (!$lock) { // Lock attempt timed out. $progress->progress(4); $progress->end_progress(); debugging('Could not get lock on ' . $qubaids->get_hash_code() . ' (Quiz ID ' . $quiz->id . ') after ' . $locktimeout . ' seconds'); return [null, null]; } } try { if ($quizcalc->get_last_calculated_time($qubaids) === false) { if (!$calculateifrequired) { $progress->progress(4); $progress->end_progress(); $lock->release(); return [null, null]; } // Recalculate now. $questionstats = $qcalc->calculate($qubaids); $progress->progress(2); $quizstats = $quizcalc->calculate( $quiz->id, $whichattempts, $groupstudentsjoins, count($questions), $qcalc->get_sum_of_mark_variance() ); $progress->progress(3); } else { $quizstats = $quizcalc->get_cached($qubaids); $progress->progress(2); $questionstats = $qcalc->get_cached($qubaids); $progress->progress(3); } if ($quizstats->s() && $performanalysis) { $subquestions = $questionstats->get_sub_questions(); $this->analyse_responses_for_all_questions_and_subquestions( $questions, $subquestions, $qubaids, $whichtries, $progress ); } $progress->progress(4); $progress->end_progress(); } finally { $lock->release(); } return [$quizstats, $questionstats]; } /** * Appropriate instance depending if we want html output for the user or not. * * @return \core\progress\base child of \core\progress\base to handle the display (or not) of task progress. */ protected function get_progress_trace_instance() { if ($this->progress === null) { if (!$this->table->is_downloading()) { $this->progress = new \core\progress\display_if_slow(get_string('calculatingallstats', 'quiz_statistics')); $this->progress->set_display_names(); } else { $this->progress = new \core\progress\none(); } } return $this->progress; } /** * Analyse responses for all questions and sub questions in this quiz. * * @param stdClass[] $questions as returned by self::load_and_initialise_questions_for_calculations * @param stdClass[] $subquestions full question objects. * @param qubaid_condition $qubaids the question usages whose responses to analyse. * @param string $whichtries which tries to analyse \question_attempt::FIRST_TRY, LAST_TRY or ALL_TRIES. * @param null|\core\progress\base $progress Used to indicate progress of task. */ protected function analyse_responses_for_all_questions_and_subquestions($questions, $subquestions, $qubaids, $whichtries, $progress = null) { if ($progress === null) { $progress = new \core\progress\none(); } // Starting response analysis tasks. $progress->start_progress('', count($questions) + count($subquestions)); $done = $this->analyse_responses_for_questions($questions, $qubaids, $whichtries, $progress); $this->analyse_responses_for_questions($subquestions, $qubaids, $whichtries, $progress, $done); // Finished all response analysis tasks. $progress->end_progress(); } /** * Analyse responses for an array of questions or sub questions. * * @param stdClass[] $questions as returned by self::load_and_initialise_questions_for_calculations. * @param qubaid_condition $qubaids the question usages whose responses to analyse. * @param string $whichtries which tries to analyse \question_attempt::FIRST_TRY, LAST_TRY or ALL_TRIES. * @param null|\core\progress\base $progress Used to indicate progress of task. * @param int[] $done array keys are ids of questions that have been analysed before calling method. * @return array array keys are ids of questions that were analysed after this method call. */ protected function analyse_responses_for_questions($questions, $qubaids, $whichtries, $progress = null, $done = []) { $countquestions = count($questions); if (!$countquestions) { return []; } if ($progress === null) { $progress = new \core\progress\none(); } $progress->start_progress('', $countquestions, $countquestions); foreach ($questions as $question) { $progress->increment_progress(); if (question_bank::get_qtype($question->qtype, false)->can_analyse_responses() && !isset($done[$question->id])) { $responesstats = new analyser($question, $whichtries); $responesstats->calculate($qubaids, $whichtries); } $done[$question->id] = 1; } $progress->end_progress(); return $done; } /** * Return a little form for the user to request to download the full report, including quiz stats and response analysis for * all questions and sub-questions. * * @param moodle_url $reporturl the base URL of the report. * @return string HTML. */ protected function everything_download_options(moodle_url $reporturl) { global $OUTPUT; return $OUTPUT->download_dataformat_selector(get_string('downloadeverything', 'quiz_statistics'), $reporturl->out_omit_querystring(), 'download', $reporturl->params() + ['everything' => 1]); } /** * Return HTML for a message that says when the stats were last calculated and a 'recalculate now' button. * * @param int $lastcachetime the time the stats were last cached. * @param int $quizid the quiz id. * @param \core\dml\sql_join $groupstudentsjoins (joins, wheres, params) for students in the group * or empty array if groups not used. * @param string $whichattempts which attempts to use, represented internally as one of the constants as used in * $quiz->grademethod ie. * QUIZ_GRADEAVERAGE, QUIZ_GRADEHIGHEST, QUIZ_ATTEMPTLAST or QUIZ_ATTEMPTFIRST * we calculate stats based on which attempts would affect the grade for each student. * @param moodle_url $reporturl url for this report * @return string HTML. */ protected function output_caching_info($lastcachetime, $quizid, $groupstudentsjoins, $whichattempts, $reporturl) { global $DB, $OUTPUT; if (empty($lastcachetime)) { return ''; } // Find the number of attempts since the cached statistics were computed. list($fromqa, $whereqa, $qaparams) = quiz_statistics_attempts_sql($quizid, $groupstudentsjoins, $whichattempts, true); $count = $DB->count_records_sql(" SELECT COUNT(1) FROM $fromqa WHERE $whereqa AND quiza.timefinish > {$lastcachetime}", $qaparams); if (!$count) { $count = 0; } // Generate the output. $a = new stdClass(); $a->lastcalculated = format_time(time() - $lastcachetime); $a->count = $count; $recalcualteurl = new moodle_url($reporturl, ['recalculate' => 1, 'sesskey' => sesskey()]); $output = ''; $output .= $OUTPUT->box_start( 'boxaligncenter generalbox boxwidthnormal mdl-align', 'cachingnotice'); $output .= get_string('lastcalculated', 'quiz_statistics', $a); $output .= $OUTPUT->single_button($recalcualteurl, get_string('recalculatenow', 'quiz_statistics')); $output .= $OUTPUT->box_end(true); return $output; } /** * Clear the cached data for a particular report configuration. This will trigger a re-computation the next time the report * is displayed. * * @param $qubaids qubaid_condition */ public function clear_cached_data($qubaids) { global $DB; $DB->delete_records('quiz_statistics', ['hashcode' => $qubaids->get_hash_code()]); $DB->delete_records('question_statistics', ['hashcode' => $qubaids->get_hash_code()]); $DB->delete_records('question_response_analysis', ['hashcode' => $qubaids->get_hash_code()]); } /** * Load the questions in this quiz and add some properties to the objects needed in the reports. * * @param stdClass $quiz the quiz. * @return array of questions for this quiz. */ public function load_and_initialise_questions_for_calculations($quiz) { // Load the questions. $questions = quiz_report_get_significant_questions($quiz); $questiondata = []; foreach ($questions as $qs => $question) { $displaynumber = $question->displaynumber; if ($question->qtype === 'random') { $question->id = 0; $question->name = get_string('random', 'quiz'); $question->questiontext = get_string('random', 'quiz'); $question->parenttype = 'random'; $question->number = $displaynumber; $questiondata[$question->slot] = $question; } else if ($question->qtype === 'missingtype') { $question->id = is_numeric($question->id) ? (int) $question->id : 0; $questiondata[$question->slot] = $question; $question->name = get_string('deletedquestion', 'qtype_missingtype'); $question->questiontext = get_string('deletedquestiontext', 'qtype_missingtype'); } else { $q = question_bank::load_question_data($question->id); $q->maxmark = $question->maxmark; $q->slot = $question->slot; $q->number = $displaynumber; $q->parenttype = null; $questiondata[$question->slot] = $q; } } return $questiondata; } /** * Output all response analysis for all questions, sub-questions and variants. For download in a number of formats. * * @param $qubaids * @param $questions * @param $questionstats * @param $reporturl * @param $whichtries string */ protected function output_all_question_response_analysis($qubaids, $questions, $questionstats, $reporturl, $whichtries = question_attempt::LAST_TRY) { foreach ($questions as $slot => $question) { if (question_bank::get_qtype( $question->qtype, false)->can_analyse_responses() ) { if ($questionstats->for_slot($slot)->get_variants()) { foreach ($questionstats->for_slot($slot)->get_variants() as $variantno) { $this->output_individual_question_response_analysis($question, $variantno, $questionstats->for_slot($slot, $variantno)->s, $reporturl, $qubaids, $whichtries); } } else { $this->output_individual_question_response_analysis($question, null, $questionstats->for_slot($slot)->s, $reporturl, $qubaids, $whichtries); } } else if ($subqids = $questionstats->for_slot($slot)->get_sub_question_ids()) { foreach ($subqids as $subqid) { if ($variants = $questionstats->for_subq($subqid)->get_variants()) { foreach ($variants as $variantno) { $this->output_individual_question_response_analysis( $questionstats->for_subq($subqid, $variantno)->question, $variantno, $questionstats->for_subq($subqid, $variantno)->s, $reporturl, $qubaids, $whichtries); } } else { $this->output_individual_question_response_analysis( $questionstats->for_subq($subqid)->question, null, $questionstats->for_subq($subqid)->s, $reporturl, $qubaids, $whichtries); } } } } } /** * Load question stats for a quiz * * @param int $quizid question usage * @param bool $calculateifrequired if true (the default) the stats will be calculated if not already stored. * If false, null will be returned if the stats are not already available. * @param bool $performanalysis if true (the default) and there are calculated stats, analysis will be performed * for each question. * @return ?all_calculated_for_qubaid_condition question stats */ public function calculate_questions_stats_for_question_bank( int $quizid, bool $calculateifrequired = true, bool $performanalysis = true, ): ?all_calculated_for_qubaid_condition { global $DB; $quiz = $DB->get_record('quiz', ['id' => $quizid], '*', MUST_EXIST); $questions = $this->load_and_initialise_questions_for_calculations($quiz); [, $questionstats] = $this->get_all_stats_and_analysis($quiz, $quiz->grademethod, question_attempt::ALL_TRIES, new \core\dml\sql_join(), $questions, null, $calculateifrequired, $performanalysis); return $questionstats; } } PK9B\9G Y Y&report/statistics/statistics_table.phpnu[. /** * Quiz statistics report, table for showing statistics of each question in the quiz. * * @package quiz_statistics * @copyright 2008 Jamie Pratt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); require_once($CFG->libdir.'/tablelib.php'); use \core_question\statistics\questions\calculated_question_summary; /** * This table has one row for each question in the quiz, with sub-rows when * random questions and variants appear. * * There are columns for the various item and position statistics. * * @copyright 2008 Jamie Pratt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class quiz_statistics_table extends flexible_table { /** @var stdClass the quiz settings. */ protected $quiz; /** @var integer the quiz course_module id. */ protected $cmid; /** * Constructor. */ public function __construct() { parent::__construct('mod-quiz-report-statistics-report'); } /** * Set up the columns and headers and other properties of the table and then * call flexible_table::setup() method. * * @param stdClass $quiz the quiz settings * @param int $cmid the quiz course_module id * @param moodle_url $reporturl the URL to redisplay this report. * @param int $s number of attempts included in the statistics. */ public function statistics_setup($quiz, $cmid, $reporturl, $s) { $this->quiz = $quiz; $this->cmid = $cmid; // Define the table columns. $columns = []; $headers = []; $columns[] = 'number'; $headers[] = get_string('questionnumber', 'quiz_statistics'); if (!$this->is_downloading()) { $columns[] = 'icon'; $headers[] = ''; $columns[] = 'actions'; $headers[] = ''; } else { $columns[] = 'qtype'; $headers[] = get_string('questiontype', 'quiz_statistics'); } $columns[] = 'name'; $headers[] = get_string('questionname', 'quiz'); $columns[] = 's'; $headers[] = get_string('attempts', 'quiz_statistics'); if ($s > 1) { $columns[] = 'facility'; $headers[] = get_string('facility', 'quiz_statistics'); $columns[] = 'sd'; $headers[] = get_string('standarddeviationq', 'quiz_statistics'); } $columns[] = 'random_guess_score'; $headers[] = get_string('random_guess_score', 'quiz_statistics'); $columns[] = 'intended_weight'; $headers[] = get_string('intended_weight', 'quiz_statistics'); $columns[] = 'effective_weight'; $headers[] = get_string('effective_weight', 'quiz_statistics'); $columns[] = 'discrimination_index'; $headers[] = get_string('discrimination_index', 'quiz_statistics'); $columns[] = 'discriminative_efficiency'; $headers[] = get_string('discriminative_efficiency', 'quiz_statistics'); $this->define_columns($columns); $this->define_headers($headers); $this->sortable(false); $this->column_class('s', 'numcol'); $this->column_class('facility', 'numcol'); $this->column_class('sd', 'numcol'); $this->column_class('random_guess_score', 'numcol'); $this->column_class('intended_weight', 'numcol'); $this->column_class('effective_weight', 'numcol'); $this->column_class('discrimination_index', 'numcol'); $this->column_class('discriminative_efficiency', 'numcol'); // Set up the table. $this->define_baseurl($reporturl->out()); $this->collapsible(true); $this->set_attribute('id', 'questionstatistics'); $this->set_attribute('class', 'generaltable generalbox boxaligncenter'); parent::setup(); } /** * Open a div tag to wrap statistics table. */ public function wrap_html_start() { // Horrible Moodle 2.0 wide-content work-around. if (!$this->is_downloading()) { echo html_writer::start_tag('div', ['id' => 'tablecontainer', 'class' => 'statistics-tablecontainer']); } } /** * Close a statistics table div. */ public function wrap_html_finish() { if (!$this->is_downloading()) { echo html_writer::end_tag('div'); } } /** * The question number. * @param \core_question\statistics\questions\calculated $questionstat stats for the question. * @return string contents of this table cell. */ protected function col_number($questionstat) { if ($this->is_calculated_question_summary($questionstat)) { return ''; } if (!isset($questionstat->question->number)) { return ''; } $number = $questionstat->question->number; if (isset($questionstat->subqdisplayorder)) { $number = $number . '.'.$questionstat->subqdisplayorder; } if ($questionstat->question->qtype != 'random' && !is_null($questionstat->variant)) { $number = $number . '.'.$questionstat->variant; } return $number; } /** * The question type icon. * @param \core_question\statistics\questions\calculated $questionstat stats for the question. * @return string contents of this table cell. */ protected function col_icon($questionstat) { if ($this->is_calculated_question_summary($questionstat)) { return ''; } else { $questionobject = $questionstat->question; return print_question_icon($questionobject); } } /** * Actions that can be performed on the question by this user (e.g. edit or preview). * @param \core_question\statistics\questions\calculated $questionstat stats for the question. * @return string contents of this table cell. */ protected function col_actions($questionstat) { if ($this->is_calculated_question_summary($questionstat)) { return ''; } else if ($questionstat->question->qtype === 'missingtype') { return ''; } else { return quiz_question_action_icons($this->quiz, $this->cmid, $questionstat->question, $this->baseurl, $questionstat->variant); } } /** * The question type name. * * @param \core_question\statistics\questions\calculated $questionstat stats for the question. * @return string contents of this table cell. */ protected function col_qtype($questionstat) { return question_bank::get_qtype_name($questionstat->question->qtype); } /** * The question name. * * @param \core_question\statistics\questions\calculated $questionstat stats for the question. * @return string contents of this table cell. */ protected function col_name($questionstat) { $name = $questionstat->question->name; if (!is_null($questionstat->variant)) { $a = new stdClass(); $a->name = $name; $a->variant = $questionstat->variant; $name = get_string('nameforvariant', 'quiz_statistics', $a); } if ($this->is_downloading()) { return $name; } $baseurl = new moodle_url($this->baseurl); if (!is_null($questionstat->variant)) { if ($questionstat->subquestion) { // Variant of a sub-question. $url = new moodle_url($baseurl, ['qid' => $questionstat->questionid, 'variant' => $questionstat->variant]); $name = html_writer::link($url, $name, ['title' => get_string('detailedanalysisforvariant', 'quiz_statistics', $questionstat->variant)]); } else if ($questionstat->slot) { // Variant of a question in a slot. $url = new moodle_url($baseurl, ['slot' => $questionstat->slot, 'variant' => $questionstat->variant]); $name = html_writer::link($url, $name, ['title' => get_string('detailedanalysisforvariant', 'quiz_statistics', $questionstat->variant)]); } } else { if ($questionstat->subquestion && !$questionstat->get_variants()) { // Sub question without variants. $url = new moodle_url($baseurl, ['qid' => $questionstat->questionid]); $name = html_writer::link($url, $name, ['title' => get_string('detailedanalysis', 'quiz_statistics')]); } else if ($baseurl->param('slot') === null && $questionstat->slot) { // Question in a slot, we are not on a page showing structural analysis of one slot, // we don't want linking on those pages. $number = $questionstat->question->number; $israndomquestion = $questionstat->question->qtype == 'random'; $url = new moodle_url($baseurl, ['slot' => $questionstat->slot]); if ($this->is_calculated_question_summary($questionstat)) { // Only make the random question summary row name link to the slot structure // analysis page with specific text to clearly indicate the link to the user. // Random and variant question rows will render the name without a link to improve clarity // in the UI. $name = html_writer::div(get_string('rangeofvalues', 'quiz_statistics')); } else if (!$israndomquestion && !$questionstat->get_variants() && !$questionstat->get_sub_question_ids()) { // Question cannot be broken down into sub-questions or variants. Link will show response analysis page. $name = html_writer::link($url, $name, ['title' => get_string('detailedanalysis', 'quiz_statistics')]); } } } if ($this->is_dubious_question($questionstat)) { $name = html_writer::tag('div', $name, ['class' => 'dubious']); } if ($this->is_calculated_question_summary($questionstat)) { $name .= html_writer::link($url, get_string('viewanalysis', 'quiz_statistics')); } else if (!empty($questionstat->minmedianmaxnotice)) { $name = get_string($questionstat->minmedianmaxnotice, 'quiz_statistics') . '
                ' . $name; } return $name; } /** * The number of attempts at this question. * * @param \core_question\statistics\questions\calculated $questionstat stats for the question. * @return string contents of this table cell. */ protected function col_s($questionstat) { if ($this->is_calculated_question_summary($questionstat)) { list($min, $max) = $questionstat->get_min_max_of('s'); $min = $min ?: 0; $max = $max ?: 0; return $this->format_range($min, $max); } else if (!isset($questionstat->s)) { return 0; } else { return $questionstat->s; } } /** * The facility index (average fraction). * @param \core_question\statistics\questions\calculated $questionstat stats for the question. * @return string contents of this table cell. */ protected function col_facility($questionstat) { if ($this->is_calculated_question_summary($questionstat)) { list($min, $max) = $questionstat->get_min_max_of('facility'); return $this->format_percentage_range($min, $max); } else if (is_null($questionstat->facility)) { return ''; } else { return $this->format_percentage($questionstat->facility); } } /** * The standard deviation of the fractions. * @param \core_question\statistics\questions\calculated $questionstat stats for the question. * @return string contents of this table cell. */ protected function col_sd($questionstat) { if ($this->is_calculated_question_summary($questionstat)) { list($min, $max) = $questionstat->get_min_max_of('sd'); return $this->format_percentage_range($min, $max); } else if (is_null($questionstat->sd) || $questionstat->maxmark == 0) { return ''; } else { return $this->format_percentage($questionstat->sd / $questionstat->maxmark); } } /** * An estimate of the fraction a student would get by guessing randomly. * @param \core_question\statistics\questions\calculated $questionstat stats for the question. * @return string contents of this table cell. */ protected function col_random_guess_score($questionstat) { if ($this->is_calculated_question_summary($questionstat)) { list($min, $max) = $questionstat->get_min_max_of('randomguessscore'); return $this->format_percentage_range($min, $max); } else if (is_null($questionstat->randomguessscore)) { return ''; } else { return $this->format_percentage($questionstat->randomguessscore); } } /** * The intended question weight. Maximum mark for the question as a percentage * of maximum mark for the quiz. That is, the indended influence this question * on the student's overall mark. * @param \core_question\statistics\questions\calculated $questionstat stats for the question. * @return string contents of this table cell. */ protected function col_intended_weight($questionstat) { if ($this->is_calculated_question_summary($questionstat)) { list($min, $max) = $questionstat->get_min_max_of('maxmark'); if (is_null($min) && is_null($max)) { return ''; } else { $min = quiz_report_scale_summarks_as_percentage($min, $this->quiz); $max = quiz_report_scale_summarks_as_percentage($max, $this->quiz); return $this->format_range($min, $max); } } else { return quiz_report_scale_summarks_as_percentage($questionstat->maxmark, $this->quiz); } } /** * The effective question weight. That is, an estimate of the actual * influence this question has on the student's overall mark. * @param \core_question\statistics\questions\calculated $questionstat stats for the question. * @return string contents of this table cell. */ protected function col_effective_weight($questionstat) { global $OUTPUT; if ($this->is_calculated_question_summary($questionstat)) { list($min, $max) = $questionstat->get_min_max_of('effectiveweight'); if (is_null($min) && is_null($max)) { return ''; } else { list( , $negcovar) = $questionstat->get_min_max_of('negcovar'); if ($negcovar) { $min = get_string('negcovar', 'quiz_statistics'); } return $this->format_range($min, $max); } } else if (is_null($questionstat->effectiveweight)) { return ''; } else if ($questionstat->negcovar) { $negcovar = get_string('negcovar', 'quiz_statistics'); if (!$this->is_downloading()) { $negcovar = html_writer::tag('div', $negcovar . $OUTPUT->help_icon('negcovar', 'quiz_statistics'), ['class' => 'negcovar']); } return $negcovar; } else { return $this->format_percentage($questionstat->effectiveweight, false); } } /** * Discrimination index. This is the product moment correlation coefficient * between the fraction for this question, and the average fraction for the * other questions in this quiz. * @param \core_question\statistics\questions\calculated $questionstat stats for the question. * @return string contents of this table cell. */ protected function col_discrimination_index($questionstat) { if ($this->is_calculated_question_summary($questionstat)) { list($min, $max) = $questionstat->get_min_max_of('discriminationindex'); if (isset($max)) { $min = $min ?: 0; } if (is_numeric($min)) { $min = $this->format_percentage($min, false); } if (is_numeric($max)) { $max = $this->format_percentage($max, false); } return $this->format_range($min, $max); } else if (!is_numeric($questionstat->discriminationindex)) { return $questionstat->discriminationindex; } else { return $this->format_percentage($questionstat->discriminationindex, false); } } /** * Discrimination efficiency, similar to, but different from, the Discrimination index. * * @param \core_question\statistics\questions\calculated $questionstat stats for the question. * @return string contents of this table cell. */ protected function col_discriminative_efficiency($questionstat) { if ($this->is_calculated_question_summary($questionstat)) { list($min, $max) = $questionstat->get_min_max_of('discriminativeefficiency'); if (!is_numeric($min) && !is_numeric($max)) { return ''; } else { return $this->format_percentage_range($min, $max, false); } } else if (!is_numeric($questionstat->discriminativeefficiency)) { return ''; } else { return $this->format_percentage($questionstat->discriminativeefficiency, false); } } /** * This method encapsulates the test for wheter a question should be considered dubious. * @param \core_question\statistics\questions\calculated $questionstat stats for the question. * @return bool is this question possibly not pulling it's weight? */ protected function is_dubious_question($questionstat) { if ($this->is_calculated_question_summary($questionstat)) { // We only care about the minimum value here. // If the minimum value is less than the threshold, then we know that there is at least one value below the threshold. list($discriminativeefficiency) = $questionstat->get_min_max_of('discriminativeefficiency'); } else { $discriminativeefficiency = $questionstat->discriminativeefficiency; } if (!is_numeric($discriminativeefficiency)) { return false; } return $discriminativeefficiency < 15; } /** * Check if the given stats object is an instance of calculated_question_summary. * * @param \core_question\statistics\questions\calculated $questionstat Stats object * @return bool */ protected function is_calculated_question_summary($questionstat) { return $questionstat instanceof calculated_question_summary; } /** * Format inputs to represent a range between $min and $max. * This function does not check if $min is less than $max or not. * If both $min and $max are equal to null, this function returns an empty string. * * @param string|null $min The minimum value in the range * @param string|null $max The maximum value in the range * @return string */ protected function format_range(?string $min = null, ?string $max = null) { if (is_null($min) && is_null($max)) { return ''; } else { $a = new stdClass(); $a->min = $min; $a->max = $max; return get_string('rangebetween', 'quiz_statistics', $a); } } /** * Format a number to a localised percentage with specified decimal points. * * @param float $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 */ protected function format_percentage(float $number, bool $fraction = true, int $decimals = 2) { $coefficient = $fraction ? 100 : 1; return get_string('percents', 'moodle', format_float($number * $coefficient, $decimals)); } /** * Format $min and $max to localised percentages and form a string that represents a range between them. * This function does not check if $min is less than $max or not. * If both $min and $max are equal to null, this function returns an empty string. * * @param float|null $min The minimum value of the range * @param float|null $max The maximum value of the range * @param bool $fraction An indicator for whether min and max are a fractions or are already multiplied by 100 * @param int $decimals Sets the number of decimal points * @return string A formatted string that represents a range between $min to $max. */ protected function format_percentage_range(?float $min = null, ?float $max = null, bool $fraction = true, int $decimals = 2) { if (is_null($min) && is_null($max)) { return ''; } else { $min = $min ?: 0; $max = $max ?: 0; return $this->format_range( $this->format_percentage($min, $fraction, $decimals), $this->format_percentage($max, $fraction, $decimals) ); } } } PK9B\ly %report/statistics/statistics_form.phpnu[. /** * Quiz statistics settings form definition. * * @package quiz_statistics * @copyright 2014 Open University * @author James Pratt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); require_once($CFG->libdir . '/formslib.php'); /** * This is the settings form for the quiz statistics report. * * @package quiz_statistics * @copyright 2014 Open University * @author James Pratt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class quiz_statistics_settings_form extends moodleform { protected function definition() { $mform = $this->_form; $mform->addElement('header', 'preferencespage', get_string('reportsettings', 'quiz_statistics')); $options = []; foreach (array_keys(quiz_get_grading_options()) as $which) { $options[$which] = \quiz_statistics\calculator::using_attempts_lang_string($which); } $mform->addElement('select', 'whichattempts', get_string('calculatefrom', 'quiz_statistics'), $options); if (quiz_allows_multiple_tries($this->_customdata['quiz'])) { $mform->addElement('select', 'whichtries', get_string('whichtries', 'quiz_statistics'), [ question_attempt::FIRST_TRY => get_string('firsttry', 'question'), question_attempt::LAST_TRY => get_string('lasttry', 'question'), question_attempt::ALL_TRIES => get_string('alltries', 'question')] ); $mform->setDefault('whichtries', question_attempt::LAST_TRY); } $mform->addElement('submit', 'submitbutton', get_string('preferencessave', 'quiz_overview')); } } PK9B\1dd0report/statistics/tests/fixtures/questions02.csvnu[slot,type,which,cat,mark,overrides.hint.0.text,overrides.hint.0.format,overrides.hint.1.text,overrides.hint.1.format,overrides.hint.2.text,overrides.hint.2.format,overrides.hint.3.text,overrides.hint.3.format,overrides.hint.4.text,overrides.hint.4.format,overrides.shuffleanswers 1,match,,maincat,1,"Hint 1",0,"Hint 2",0,"Hint 3",0,"Hint 4",0,"Hint 5",0,0 PK9B\KG0report/statistics/tests/fixtures/questions01.csvnu[slot,type,which,cat,mark,overrides.hint.0.text,overrides.hint.0.format,overrides.hint.1.text,overrides.hint.1.format,overrides.hint.2.text,overrides.hint.2.format,overrides.hint.3.text,overrides.hint.3.format,overrides.shuffleanswers 1,random,,rand,1,"Hint 1",0,"Hint 2",0,"Hint 3",0,"Hint 4",0,0 ,shortanswer,,rand,1,"Hint 1",0,"Hint 2",0,"Hint 3",0,"Hint 4",0,0 ,numerical,,rand,1,"Hint 1",0,"Hint 2",0,"Hint 3",0,"Hint 4",0,0 2,calculatedsimple,sumwithvariants,maincat,1,"Hint 1",0,"Hint 2",0,"Hint 3",0,"Hint 4",0,0 3,match,,maincat,1,"Hint 1",0,"Hint 2",0,"Hint 3",0,"Hint 4",0,0 4,truefalse,,maincat,1,"Hint 1",0,"Hint 2",0,"Hint 3",0,"Hint 4",0,0 PK9B\ f %%8report/statistics/tests/fixtures/mdl_question_states.csvnu[id,sumgrades,questionid,slot,maxmark,mark,variant 39872,12,1,1,1,0,1 39873,12,2,2,1,0,1 39874,12,3,3,1,0,1 39875,12,4,4,1,0,1 39896,12,5,5,0,0,1 39897,12,6,6,0,0,1 39898,12,7,7,0,0,1 39899,12,8,8,8,5.5,1 39900,12,14,9,1,0.3,1 39901,12,15,10,1,0.9,1 39902,12,16,11,1,1,1 39903,12,17,12,1,0.3,1 39910,12,18,19,1,0,1 39911,12,19,20,1,1,1 39908,12,20,17,1,1,1 39909,12,21,18,1,0,1 39905,12,22,14,1,0,1 39906,12,23,15,1,0,1 39907,12,24,16,1,1,1 39904,12,25,13,1,1,1 39992,7.4,1,1,1,0,1 39993,7.4,2,2,1,0,1 39994,7.4,3,3,1,0,1 39995,7.4,4,4,1,0,1 40016,7.4,5,5,0,0,1 40017,7.4,6,6,0,0,1 40018,7.4,7,7,0,0,1 40019,7.4,8,8,8,1,1 40020,7.4,14,9,1,0.9,1 40021,7.4,15,10,1,0.9,1 40022,7.4,16,11,1,0.3,1 40023,7.4,17,12,1,0.3,1 40030,7.4,18,19,1,0,1 40031,7.4,19,20,1,1,1 40028,7.4,20,17,1,0,1 40029,7.4,21,18,1,1,1 40025,7.4,22,14,1,0,1 40026,7.4,23,15,1,0,1 40027,7.4,24,16,1,1,1 40024,7.4,25,13,1,1,1 40032,11.7,1,1,1,0,1 40033,11.7,2,2,1,0,1 40034,11.7,3,3,1,0,1 40035,11.7,4,4,1,0,1 40056,11.7,5,5,0,0,1 40057,11.7,6,6,0,0,1 40058,11.7,7,7,0,0,1 40059,11.7,8,8,8,2.5,1 40060,11.7,14,9,1,1,1 40061,11.7,15,10,1,0.9,1 40062,11.7,16,11,1,0.3,1 40063,11.7,17,12,1,1,1 40070,11.7,18,19,1,1,1 40071,11.7,19,20,1,1,1 40068,11.7,20,17,1,1,1 40069,11.7,21,18,1,1,1 40065,11.7,22,14,1,0,1 40066,11.7,23,15,1,1,1 40067,11.7,24,16,1,1,1 40064,11.7,25,13,1,0,1 39352,8.5,1,1,1,0,1 39353,8.5,2,2,1,0,1 39354,8.5,3,3,1,0,1 39355,8.5,4,4,1,0,1 39376,8.5,5,5,0,0,1 39377,8.5,6,6,0,0,1 39378,8.5,7,7,0,0,1 39379,8.5,8,8,8,4,1 39380,8.5,14,9,1,0.3,1 39381,8.5,15,10,1,0.9,1 39382,8.5,16,11,1,1,1 39383,8.5,17,12,1,0.3,1 39390,8.5,18,19,1,0,1 39391,8.5,19,20,1,1,1 39388,8.5,20,17,1,1,1 39389,8.5,21,18,1,0,1 39385,8.5,22,14,1,0,1 39386,8.5,23,15,1,0,1 39387,8.5,24,16,1,0,1 39384,8.5,25,13,1,0,1 37112,10.1,1,1,1,0,1 37113,10.1,2,2,1,0,1 37114,10.1,3,3,1,0,1 37115,10.1,4,4,1,0,1 37136,10.1,5,5,0,0,1 37137,10.1,6,6,0,0,1 37138,10.1,7,7,0,0,1 37139,10.1,8,8,8,3,1 37140,10.1,14,9,1,0.9,1 37141,10.1,15,10,1,0.3,1 37142,10.1,16,11,1,0.9,1 37143,10.1,17,12,1,1,1 37150,10.1,18,19,1,1,1 37151,10.1,19,20,1,0,1 37148,10.1,20,17,1,1,1 37149,10.1,21,18,1,1,1 37145,10.1,22,14,1,1,1 37146,10.1,23,15,1,0,1 37147,10.1,24,16,1,0,1 37144,10.1,25,13,1,0,1 37432,11.3,1,1,1,0,1 37433,11.3,2,2,1,0,1 37434,11.3,3,3,1,0,1 37435,11.3,4,4,1,0,1 37456,11.3,5,5,0,0,1 37457,11.3,6,6,0,0,1 37458,11.3,7,7,0,0,1 37459,11.3,8,8,8,4.5,1 37460,11.3,14,9,1,0.9,1 37461,11.3,15,10,1,1,1 37462,11.3,16,11,1,1,1 37463,11.3,17,12,1,0.9,1 37470,11.3,18,19,1,0,1 37471,11.3,19,20,1,0,1 37468,11.3,20,17,1,0,1 37469,11.3,21,18,1,0,1 37465,11.3,22,14,1,1,1 37466,11.3,23,15,1,1,1 37467,11.3,24,16,1,1,1 37464,11.3,25,13,1,0,1 37472,11.2,1,1,1,0,1 37473,11.2,2,2,1,0,1 37474,11.2,3,3,1,0,1 37475,11.2,4,4,1,0,1 37496,11.2,5,5,0,0,1 37497,11.2,6,6,0,0,1 37498,11.2,7,7,0,0,1 37499,11.2,8,8,8,3,1 37500,11.2,14,9,1,0.3,1 37501,11.2,15,10,1,0.3,1 37502,11.2,16,11,1,0.3,1 37503,11.2,17,12,1,0.3,1 37510,11.2,18,19,1,1,1 37511,11.2,19,20,1,1,1 37508,11.2,20,17,1,1,1 37509,11.2,21,18,1,1,1 37505,11.2,22,14,1,0,1 37506,11.2,23,15,1,1,1 37507,11.2,24,16,1,1,1 37504,11.2,25,13,1,1,1 37632,14.1,1,1,1,0,1 37633,14.1,2,2,1,0,1 37634,14.1,3,3,1,0,1 37635,14.1,4,4,1,0,1 37656,14.1,5,5,0,0,1 37657,14.1,6,6,0,0,1 37658,14.1,7,7,0,0,1 37659,14.1,8,8,8,6,1 37660,14.1,14,9,1,0.9,1 37661,14.1,15,10,1,0.9,1 37662,14.1,16,11,1,1,1 37663,14.1,17,12,1,0.3,1 37670,14.1,18,19,1,0,1 37671,14.1,19,20,1,1,1 37668,14.1,20,17,1,1,1 37669,14.1,21,18,1,0,1 37665,14.1,22,14,1,1,1 37666,14.1,23,15,1,1,1 37667,14.1,24,16,1,1,1 37664,14.1,25,13,1,0,1 37672,8.6,1,1,1,0,1 37673,8.6,2,2,1,0,1 37674,8.6,3,3,1,0,1 37675,8.6,4,4,1,0,1 37696,8.6,5,5,0,0,1 37697,8.6,6,6,0,0,1 37698,8.6,7,7,0,0,1 37699,8.6,8,8,8,0.5,1 37700,8.6,14,9,1,0.9,1 37701,8.6,15,10,1,1,1 37702,8.6,16,11,1,0.9,1 37703,8.6,17,12,1,0.3,1 37710,8.6,18,19,1,0,1 37711,8.6,19,20,1,1,1 37708,8.6,20,17,1,1,1 37709,8.6,21,18,1,1,1 37705,8.6,22,14,1,1,1 37706,8.6,23,15,1,1,1 37707,8.6,24,16,1,0,1 37704,8.6,25,13,1,0,1 37712,11.6,1,1,1,0,1 37713,11.6,2,2,1,0,1 37714,11.6,3,3,1,0,1 37715,11.6,4,4,1,0,1 37736,11.6,5,5,0,0,1 37737,11.6,6,6,0,0,1 37738,11.6,7,7,0,0,1 37739,11.6,8,8,8,5.5,1 37740,11.6,14,9,1,0.9,1 37741,11.6,15,10,1,1,1 37742,11.6,16,11,1,0.9,1 37743,11.6,17,12,1,0.3,1 37750,11.6,18,19,1,0,1 37751,11.6,19,20,1,0,1 37748,11.6,20,17,1,1,1 37749,11.6,21,18,1,0,1 37745,11.6,22,14,1,1,1 37746,11.6,23,15,1,0,1 37747,11.6,24,16,1,0,1 37744,11.6,25,13,1,1,1 38072,9.3,1,1,1,0,1 38073,9.3,2,2,1,0,1 38074,9.3,3,3,1,0,1 38075,9.3,4,4,1,0,1 38096,9.3,5,5,0,0,1 38097,9.3,6,6,0,0,1 38098,9.3,7,7,0,0,1 38099,9.3,8,8,8,2.5,1 38100,9.3,14,9,1,0.9,1 38101,9.3,15,10,1,1,1 38102,9.3,16,11,1,1,1 38103,9.3,17,12,1,0.9,1 38110,9.3,18,19,1,0,1 38111,9.3,19,20,1,1,1 38108,9.3,20,17,1,1,1 38109,9.3,21,18,1,0,1 38105,9.3,22,14,1,1,1 38106,9.3,23,15,1,0,1 38107,9.3,24,16,1,0,1 38104,9.3,25,13,1,0,1 38232,13.1,1,1,1,0,1 38233,13.1,2,2,1,0,1 38234,13.1,3,3,1,0,1 38235,13.1,4,4,1,0,1 38256,13.1,5,5,0,0,1 38257,13.1,6,6,0,0,1 38258,13.1,7,7,0,0,1 38259,13.1,8,8,8,4,1 38260,13.1,14,9,1,0.9,1 38261,13.1,15,10,1,0.3,1 38262,13.1,16,11,1,0.9,1 38263,13.1,17,12,1,1,1 38270,13.1,18,19,1,0,1 38271,13.1,19,20,1,1,1 38268,13.1,20,17,1,1,1 38269,13.1,21,18,1,1,1 38265,13.1,22,14,1,1,1 38266,13.1,23,15,1,1,1 38267,13.1,24,16,1,1,1 38264,13.1,25,13,1,0,1 38272,11.5,1,1,1,0,1 38273,11.5,2,2,1,0,1 38274,11.5,3,3,1,0,1 38275,11.5,4,4,1,0,1 38296,11.5,5,5,0,0,1 38297,11.5,6,6,0,0,1 38298,11.5,7,7,0,0,1 38299,11.5,8,8,8,4,1 38300,11.5,14,9,1,0.9,1 38301,11.5,15,10,1,0.3,1 38302,11.5,16,11,1,0.3,1 38303,11.5,17,12,1,1,1 38310,11.5,18,19,1,1,1 38311,11.5,19,20,1,0,1 38308,11.5,20,17,1,0,1 38309,11.5,21,18,1,1,1 38305,11.5,22,14,1,1,1 38306,11.5,23,15,1,1,1 38307,11.5,24,16,1,0,1 38304,11.5,25,13,1,1,1 38472,11.3,1,1,1,0,1 38473,11.3,2,2,1,0,1 38474,11.3,3,3,1,0,1 38475,11.3,4,4,1,0,1 38496,11.3,5,5,0,0,1 38497,11.3,6,6,0,0,1 38498,11.3,7,7,0,0,1 38499,11.3,8,8,8,3,1 38500,11.3,14,9,1,1,1 38501,11.3,15,10,1,1,1 38502,11.3,16,11,1,0.3,1 38503,11.3,17,12,1,1,1 38510,11.3,18,19,1,1,1 38511,11.3,19,20,1,0,1 38508,11.3,20,17,1,1,1 38509,11.3,21,18,1,1,1 38505,11.3,22,14,1,1,1 38506,11.3,23,15,1,0,1 38507,11.3,24,16,1,1,1 38504,11.3,25,13,1,0,1 38632,5.5,1,1,1,0,1 38633,5.5,2,2,1,0,1 38634,5.5,3,3,1,0,1 38635,5.5,4,4,1,0,1 38656,5.5,5,5,0,0,1 38657,5.5,6,6,0,0,1 38658,5.5,7,7,0,0,1 38659,5.5,8,8,8,1,1 38660,5.5,14,9,1,1,1 38661,5.5,15,10,1,0.9,1 38662,5.5,16,11,1,0.3,1 38663,5.5,17,12,1,0.3,1 38670,5.5,18,19,1,0,1 38671,5.5,19,20,1,0,1 38668,5.5,20,17,1,0,1 38669,5.5,21,18,1,0,1 38665,5.5,22,14,1,0,1 38666,5.5,23,15,1,1,1 38667,5.5,24,16,1,0,1 38664,5.5,25,13,1,1,1 38672,4.8,1,1,1,0,1 38673,4.8,2,2,1,0,1 38674,4.8,3,3,1,0,1 38675,4.8,4,4,1,0,1 38696,4.8,5,5,0,0,1 38697,4.8,6,6,0,0,1 38698,4.8,7,7,0,0,1 38699,4.8,8,8,8,1,1 38700,4.8,14,9,1,0.3,1 38701,4.8,15,10,1,0.3,1 38702,4.8,16,11,1,0.3,1 38703,4.8,17,12,1,0.9,1 38710,4.8,18,19,1,0,1 38711,4.8,19,20,1,1,1 38708,4.8,20,17,1,0,1 38709,4.8,21,18,1,0,1 38705,4.8,22,14,1,0,1 38706,4.8,23,15,1,0,1 38707,4.8,24,16,1,1,1 38704,4.8,25,13,1,0,1 38992,8.7,1,1,1,0,1 38993,8.7,2,2,1,0,1 38994,8.7,3,3,1,0,1 38995,8.7,4,4,1,0,1 39016,8.7,5,5,0,0,1 39017,8.7,6,6,0,0,1 39018,8.7,7,7,0,0,1 39019,8.7,8,8,8,1.5,1 39020,8.7,14,9,1,1,1 39021,8.7,15,10,1,0.3,1 39022,8.7,16,11,1,0.9,1 39023,8.7,17,12,1,1,1 39030,8.7,18,19,1,0,1 39031,8.7,19,20,1,0,1 39028,8.7,20,17,1,1,1 39029,8.7,21,18,1,0,1 39025,8.7,22,14,1,1,1 39026,8.7,23,15,1,0,1 39027,8.7,24,16,1,1,1 39024,8.7,25,13,1,1,1 39032,11.8,1,1,1,0,1 39033,11.8,2,2,1,0,1 39034,11.8,3,3,1,0,1 39035,11.8,4,4,1,0,1 39056,11.8,5,5,0,0,1 39057,11.8,6,6,0,0,1 39058,11.8,7,7,0,0,1 39059,11.8,8,8,8,4,1 39060,11.8,14,9,1,0.9,1 39061,11.8,15,10,1,1,1 39062,11.8,16,11,1,0.9,1 39063,11.8,17,12,1,1,1 39070,11.8,18,19,1,0,1 39071,11.8,19,20,1,0,1 39068,11.8,20,17,1,0,1 39069,11.8,21,18,1,1,1 39065,11.8,22,14,1,1,1 39066,11.8,23,15,1,1,1 39067,11.8,24,16,1,1,1 39064,11.8,25,13,1,0,1 39272,6,1,1,1,0,1 39273,6,2,2,1,0,1 39274,6,3,3,1,0,1 39275,6,4,4,1,0,1 39296,6,5,5,0,0,1 39297,6,6,6,0,0,1 39298,6,7,7,0,0,1 39299,6,8,8,8,2,1 39300,6,14,9,1,0.9,1 39301,6,15,10,1,0.9,1 39302,6,16,11,1,0.3,1 39303,6,17,12,1,0.9,1 39310,6,18,19,1,0,1 39311,6,19,20,1,0,1 39308,6,20,17,1,0,1 39309,6,21,18,1,0,1 39305,6,22,14,1,1,1 39306,6,23,15,1,0,1 39307,6,24,16,1,0,1 39304,6,25,13,1,0,1 36192,5.9,1,1,1,0,1 36193,5.9,2,2,1,0,1 36194,5.9,3,3,1,0,1 36195,5.9,4,4,1,0,1 36216,5.9,5,5,0,0,1 36217,5.9,6,6,0,0,1 36218,5.9,7,7,0,0,1 36219,5.9,8,8,8,1,1 36220,5.9,14,9,1,1,1 36221,5.9,15,10,1,0.3,1 36222,5.9,16,11,1,0.3,1 36223,5.9,17,12,1,0.3,1 36230,5.9,18,19,1,0,1 36231,5.9,19,20,1,0,1 36228,5.9,20,17,1,1,1 36229,5.9,21,18,1,0,1 36225,5.9,22,14,1,1,1 36226,5.9,23,15,1,1,1 36227,5.9,24,16,1,0,1 36224,5.9,25,13,1,0,1 36352,12.8,1,1,1,0,1 36353,12.8,2,2,1,0,1 36354,12.8,3,3,1,0,1 36355,12.8,4,4,1,0,1 36376,12.8,5,5,0,0,1 36377,12.8,6,6,0,0,1 36378,12.8,7,7,0,0,1 36379,12.8,8,8,8,6,1 36380,12.8,14,9,1,0.9,1 36381,12.8,15,10,1,1,1 36382,12.8,16,11,1,1,1 36383,12.8,17,12,1,0.9,1 36390,12.8,18,19,1,1,1 36391,12.8,19,20,1,1,1 36388,12.8,20,17,1,0,1 36389,12.8,21,18,1,0,1 36385,12.8,22,14,1,0,1 36386,12.8,23,15,1,0,1 36387,12.8,24,16,1,1,1 36384,12.8,25,13,1,0,1 36832,13.8,1,1,1,0,1 36833,13.8,2,2,1,0,1 36834,13.8,3,3,1,0,1 36835,13.8,4,4,1,0,1 36856,13.8,5,5,0,0,1 36857,13.8,6,6,0,0,1 36858,13.8,7,7,0,0,1 36859,13.8,8,8,8,7,1 36860,13.8,14,9,1,0.9,1 36861,13.8,15,10,1,0.3,1 36862,13.8,16,11,1,0.3,1 36863,13.8,17,12,1,0.3,1 36870,13.8,18,19,1,0,1 36871,13.8,19,20,1,0,1 36868,13.8,20,17,1,1,1 36869,13.8,21,18,1,1,1 36865,13.8,22,14,1,0,1 36866,13.8,23,15,1,1,1 36867,13.8,24,16,1,1,1 36864,13.8,25,13,1,1,1 PK9B\ -report/statistics/tests/fixtures/qstats00.csvnu[slot,subqname,variant,s,facility,sd,effectiveweight,covariance,markvariance,othermarkvariance,discriminationindex,covariancemax,discriminativeefficiency,maxmark 1,,,25,0.704,0.4513682901,21.2922742344,-0.022555556,0.2037333333,0.5002777794,-7.0650767526,0.2385555565,-9.4550536967,1 1,numerical,,12,0.583333333,0.514928651,**NULL**,,,,35.803933,,39.39393939,1 2,,,25,0.48,0.5099019514,18.8979800309,-0.1172777785,0.26,0.6334555578,-28.8982125772,0.318833334,-36.7834118938,1 2,,1,6,0.50,0.5477225575,**NULL**,,,,-10.5999788,,-14.28571429,1 2,,8,5,0.40,0.547722558,**NULL**,,,,-57.77466679,,-71.05263241,1 3,,,25,0.973333332,0.13333334,4.443012573,-0.0098888894,0.0177777796,0.6609,-9.1230674268,0.045666669,-21.6545012165,1 4,,,25,0.68,0.4760952286,18.9347251357,-0.0833888893,0.2266666667,0.5990111128,-22.6306444113,0.2652222232,-31.4411395613,1 5,,,25,0.52,0.3055050463,11.1450138688,-0.0436944444,0.0933333333,0.6529555563,-17.6997047674,0.2063055556,-21.1794802584,1 6,,,25,0.64,0.4898979486,9.8081339177,-0.2015555547,0.24,0.8220111101,-45.3785178421,0.3539999995,-56.9365974439,1 7,,,25,0.62,0.331662479,15.4788602394,-0.0142499998,0.11,0.5774000005,-5.6543166602,0.2190833335,-6.5043742058,1 PK9B\).report/statistics/tests/fixtures/results00.csvnu[quizattempt,slots.1.mark,slots.2.mark,slots.3.mark,slots.4.mark,slots.5.mark,slots.6.mark,slots.7.mark,summarks 1,1,1,1,1,1,0,1,6 2,1,1,1,1,0.5,1,0,5.5 3,1,1,1,1,0.5,0,1,5.5 4,0,0,1,1,1,0,0,3 5,1,0,1,0,0,1,0.5,3.5 6,1,1,1,1,0.5,0,0.5,5 7,0,1,1,1,0.5,1,0.5,5 8,1,1,1,0,0.5,1,0.5,5 9,1,0,1,1,1,1,1,6 10,0,1,1,0,0.5,1,0.5,4 11,0.8,0,1,1,0.5,1,0.5,4.8 12,1,1,1,0,0.5,1,1,5.5 13,1,0,1,1,0.5,1,0.5,5 14,1,0,1,1,1,0,0.5,4.5 15,0,0,1,1,0,1,1,4 16,1,0,1,0,0.5,1,1,4.5 17,0.8,0,1,1,0.5,0,1,4.3 18,1,0,1,1,0,1,0.5,4.5 19,1,0,1,1,0.5,0,0.5,4 20,0,1,1,0,0.5,1,0,3.5 21,1,1,0.3333333,1,0.5,0,0.5,4.33333 22,1,1,1,0,0.5,0,0.5,4 23,1,1,1,1,0,1,0.5,5.5 24,0,0,1,0,0.5,1,1,3.5 25,0,0,1,1,1,1,1,5 PK9B\V M)dd,report/statistics/tests/fixtures/quizzes.csvnu[testnumber,preferredbehaviour 00,deferredfeedback 01,interactive 02,interactive 03,deferredfeedback PK9B\h g  0report/statistics/tests/fixtures/questions00.csvnu[slot,type,which,cat,mark 1,random,,rand,1 ,shortanswer,,rand,1 ,numerical,,rand,1 2,calculatedsimple,sumwithvariants,maincat,1 3,match,,maincat,1 4,truefalse,,maincat,1 5,multichoice,two_of_four,maincat,1 6,multichoice,one_of_four,maincat,1 7,multianswer,,maincat,1 PK9B\,report/statistics/tests/fixtures/steps01.csvnu[quizattempt,firstname,lastname,randqs.1,responses.1.-submit,responses.1.-tryagain,responses.1.answer,variants.2,responses.2.-submit,responses.2.-tryagain,responses.2.answer,responses.3.-submit,responses.3.-tryagain,responses.3.cat,responses.3.frog,responses.3.newt,responses.4.-submit,responses.4.answer,finished 1,John,Jones,shortanswer,1,,butterfly,4,,,19.4,,,amphibian,,,,1,0 1,,,,,1,butterfly,,,,19.4,1,,insect,insect,insect,,1,0 1,,,,1,,dog,,,,19.4,,1,insect,insect,insect,,1,0 1,,,,,1,dog,,,,19.4,1,,insect,insect,amphibian,,1,0 1,,,,1,,chicken,,,,19.4,,1,insect,insect,amphibian,,1,0 1,,,,,,chicken,,,,19.4,1,,mammal,insect,amphibian,,1,0 1,,,,,,chicken,,,,19.4,,1,mammal,insect,amphibian,,1,0 1,,,,,,chicken,,,,19.4,1,,amphibian,insect,amphibian,,1,0 1,,,,,,chicken,,,,19.4,,1,amphibian,insect,amphibian,,1,0 1,,,,,,chicken,,,,19.4,1,,amphibian,mammal,amphibian,,1,1 2,Han,Solo,shortanswer,1,,Tod,2,1,,19.4,1,,amphibian,amphibian,amphibian,1,0,0 2,,,,,1,Tod,,,1,19.4,,1,amphibian,amphibian,amphibian,,0,0 2,,,,1,,Tony,,,,19.4,1,,mammal,insect,insect,,0,0 2,,,,,1,Tony,,,,19.4,,1,mammal,insect,insect,,0,0 2,,,,1,,Sharon,,,,19.4,1,,insect,amphibian,mammal,,0,0 2,,,,,,Sharon,,,,19.4,,1,insect,amphibian,mammal,,0,0 2,,,,,,Sharon,,,,19.4,1,,insect,amphibian,mammal,,0,1 3,Yoda,"Wise He Is",shortanswer,1,,snake,9,1,,7,1,,insect,amphibian,amphibian,,1,0 3,,,,,1,snake,,,,7,,1,insect,amphibian,amphibian,,1,0 3,,,,1,,snake,,,,7,1,,insect,amphibian,amphibian,,1,0 3,,,,,1,snake,,,,7,,1,insect,amphibian,amphibian,,1,0 3,,,,1,,snakes,,,,7,1,,insect,amphibian,amphibian,,1,0 3,,,,,1,snakes,,,,7,,1,insect,amphibian,amphibian,,1,0 3,,,,1,,Snakes,,,,7,1,,mammal,amphibian,amphibian,,1,0 3,,,,,1,Snakes,,,,7,,,mammal,amphibian,amphibian,,1,0 3,,,,1,,SnakeS,,,,7,,,mammal,amphibian,amphibian,,1,1 4,Herbert,Garrison,shortanswer,,,dog,6,,,9.4,1,,amphibian,amphibian,amphibian,,1,0 4,,,,,,dog,,,,9.4,,1,amphibian,amphibian,amphibian,,1,0 4,,,,,,dog,,,,9.4,1,,insect,insect,insect,,1,0 4,,,,,,dog,,,,9.4,,1,insect,insect,insect,,1,0 4,,,,,,dog,,,,9.4,1,,amphibian,insect,amphibian,,1,0 4,,,,,,dog,,,,9.4,,1,amphibian,insect,amphibian,,1,0 4,,,,,,dog,,,,9.4,1,,insect,mammal,mammal,,1,1 5,Agent,Smith,numerical,1,,3.1,2,1,,x+y,,,amphibian,amphibian,mammal,,1,0 5,,,,,,3.1,,1,,4.5,,,amphibian,amphibian,mammal,,1,1 6,Agent,Smith,numerical,,,3.142,3,,,19.4,,,insect,amphibian,mammal,1,0,0 6,,,,1,,3.14,,1,,19.4,1,,insect,amphibian,mammal,,0,0 6,,,,,,3.14,,,1,19.4,,1,insect,amphibian,mammal,,0,0 6,,,,,,3.14,,1,,3.3,1,,insect,insect,amphibian,,0,1 7,Agent,Smith,numerical,1,,3.14,4,1,,19.3,,,amphibian,insect,insect,,1,1 8,Bebe,Stevens,shortanswer,1,,goat,2,1,,8.5,,,amphibian,amphibian,insect,,1,0 8,,,,,1,goat,,,,8.5,,,amphibian,amphibian,insect,,1,0 8,,,,1,,"Mexican burrowing caecilian",,,,8.5,,,amphibian,amphibian,insect,,1,0 8,,,,,1,"Mexican burrowing caecilian",,,,8.5,,,amphibian,amphibian,insect,,1,0 8,,,,1,,"Mexican burrowing caecilian",,,,8.5,,,amphibian,amphibian,insect,,1,0 8,,,,,1,"Mexican burrowing caecilian",,,,8.5,,,amphibian,amphibian,insect,,1,0 8,,,,1,,newt,,,,8.5,,,amphibian,amphibian,insect,,1,0 8,,,,,1,newt,,,,8.5,,,amphibian,amphibian,insect,,1,0 8,,,,1,,human,,,,8.5,,,amphibian,amphibian,insect,,1,1 9,Luke,Skywalker,numerical,1,,2,10,1,,555,1,,amphibian,amphibian,amphibian,1,0,0 9,,,,,1,2,,,1,555,,1,amphibian,amphibian,amphibian,,0,0 9,,,,1,,20,,1,,44,1,,amphibian,amphibian,amphibian,,0,0 9,,,,,1,20,,,1,44,,1,amphibian,amphibian,amphibian,,0,0 9,,,,1,,34,,1,,22,1,,amphibian,amphibian,amphibian,,0,0 9,,,,,,34,,,1,22,,1,amphibian,amphibian,amphibian,,0,0 9,,,,,,34,,1,,11,1,,mammal,amphibian,amphibian,,0,0 9,,,,,,34,,,1,11,,,mammal,amphibian,amphibian,,0,0 9,,,,,,34,,1,,12,,,mammal,amphibian,amphibian,,0,1 10,Luke,Skywalker,shortanswer,1,,toad,1,1,,23,1,,amphibian,insect,amphibian,,1,0 10,,,,,,toad,,,1,23,,,amphibian,insect,amphibian,,1,0 10,,,,,,toad,,1,,22,,,amphibian,insect,amphibian,,1,0 10,,,,,,toad,,,1,22,,,amphibian,insect,amphibian,,1,0 10,,,,,,toad,,1,,21,,,amphibian,insect,amphibian,,1,0 10,,,,,,toad,,,1,21,,,amphibian,insect,amphibian,,1,0 10,,,,,,toad,,1,,9,,,amphibian,insect,amphibian,,1,0 10,,,,,,toad,,,1,9,,,amphibian,insect,amphibian,,1,0 10,,,,,,toad,,1,,9.9,,,amphibian,insect,amphibian,,1,1 11,Luke,Skywalker,shortanswer,1,,toad,2,1,,8,1,,insect,insect,amphibian,,1,0 11,,,,,1,toad,,,1,8,,,insect,insect,amphibian,,1,0 11,,,,1,,toad,,1,,8,,,insect,insect,amphibian,,1,0 11,,,,,1,toad,,,1,8,,,insect,insect,amphibian,,1,0 11,,,,1,,toad,,1,,8,,,insect,insect,amphibian,,1,0 11,,,,,,toad,,,1,8,,,insect,insect,amphibian,,1,0 11,,,,,,toad,,1,,8,,,insect,insect,amphibian,,1,0 11,,,,,,toad,,,1,8,,,insect,insect,amphibian,,1,0 11,,,,,,toad,,1,,8.5,,,insect,insect,amphibian,,1,1 12,Leia,"The Princess",shortanswer,,,eggs,4,,,19.4,1,,amphibian,amphibian,,1,1,0 12,,,,,,eggs,,,,19.4,1,,amphibian,amphibian,mammal,,1,1 PK9B\{2Hl l 5report/statistics/tests/fixtures/responsecounts01.csvnu[slot,randq,variant,subpart,modelresponse,actualresponse,count1,count2,count3,count4,count5,totalcount 1,shortanswer,1,1,frog,,0,0,0,0,0,0 1,shortanswer,1,1,toad,toad,2,1,1,0,0,4 1,shortanswer,1,1,*,butterfly,1,0,0,0,0,1 1,shortanswer,1,1,*,dog,1,1,0,0,0,2 1,shortanswer,1,1,*,chicken,0,0,1,0,0,1 1,shortanswer,1,1,*,Tod,1,0,0,0,0,1 1,shortanswer,1,1,*,Tony,0,1,0,0,0,1 1,shortanswer,1,1,*,Sharon,0,0,1,0,0,1 1,shortanswer,1,1,*,snake,1,1,0,0,0,2 1,shortanswer,1,1,*,snakes,0,0,1,0,0,1 1,shortanswer,1,1,*,Snakes,0,0,0,1,0,1 1,shortanswer,1,1,*,SnakeS,0,0,0,0,1,1 1,shortanswer,1,1,*,goat,1,0,0,0,0,1 1,shortanswer,1,1,*,"Mexican burrowing caecilian",0,1,1,0,0,2 1,shortanswer,1,1,*,newt,0,0,0,1,0,1 1,shortanswer,1,1,*,human,0,0,0,0,1,1 1,shortanswer,1,1,*,eggs,1,0,0,0,0,1 1,shortanswer,1,1,"[No response]",,0,0,0,0,0,0 1,numerical,1,1,3.14 (3.14..3.14),3.14,2,0,0,0,0,2 1,numerical,1,1,3.142 (3.142..3.142),,0,0,0,0,0,0 1,numerical,1,1,3.1 (3.1..3.1),3.1,1,0,0,0,0,1 1,numerical,1,1,3 (3..3),,0,0,0,0,0,0 1,numerical,1,1,*,2,1,0,0,0,0,1 1,numerical,1,1,*,20,0,1,0,0,0,1 1,numerical,1,1,*,34,0,0,1,0,0,1 1,numerical,1,1,[No response],,0,0,0,0,0,0 2,,1,1,{a} + {b} (±0.01 Relative),9.9,0,0,0,0,1,1 2,,1,1,[Did not match any answer],23,1,0,0,0,0,1 2,,1,1,[Did not match any answer],22,0,1,0,0,0,1 2,,1,1,[Did not match any answer],21,0,0,1,0,0,1 2,,1,1,[Did not match any answer],9,0,0,0,1,0,1 2,,1,1,[No response],,0,0,0,0,0,0 2,,2,1,{a} + {b} (±0.01 Relative),8.5,1,0,0,0,1,2 2,,2,1,"[Did not match any answer]",19.4,1,1,0,0,0,2 2,,2,1,[Did not match any answer],4.5,1,0,0,0,0,1 2,,2,1,"[Did not match any answer]",8,1,1,1,1,0,4 2,,2,1,[No response],,0,0,0,0,0,0 2,,3,1,{a} + {b} (±0.01 Relative),3.3,0,1,0,0,0,1 2,,3,1,[Did not match any answer],19.4,1,0,0,0,0,1 2,,3,1,[No response],,0,0,0,0,0,0 2,,4,1,{a} + {b} (±0.01 Relative),19.4,2,0,0,0,0,2 2,,4,1,{a} + {b} (±0.01 Relative),19.3,1,0,0,0,0,1 2,,4,1,[Did not match any answer],,0,0,0,0,0,0 2,,4,1,[No response],,0,0,0,0,0,0 2,,6,1,"{a} + {b} (±0.01 Relative)",9.4,1,0,0,0,0,1 2,,6,1,"[Did not match any answer]",,0,0,0,0,0,0 2,,6,1,"[No response]",,0,0,0,0,0,0 2,,9,1,"{a} + {b} (±0.01 Relative)",,0,0,0,0,0,0 2,,9,1,"[Did not match any answer]",7,1,0,0,0,0,1 2,,9,1,"[No response]",,0,0,0,0,0,0 2,,10,1,"{a} + {b} (±0.01 Relative)",,0,0,0,0,0,0 2,,10,1,"[Did not match any answer]",555,1,0,0,0,0,1 2,,10,1,"[Did not match any answer]",44,0,1,0,0,0,1 2,,10,1,"[Did not match any answer]",22,0,0,1,0,0,1 2,,10,1,"[Did not match any answer]",11,0,0,0,1,0,1 2,,10,1,"[Did not match any answer]",12,0,0,0,0,1,1 2,,10,1,"[No response]",,0,0,0,0,0,0 3,,1,1,"frog: amphibian",amphibian,8,2,3,3,0,16 3,,1,1,frog: mammal,mammal,0,0,0,1,1,2 3,,1,1,"frog: insect",insect,4,4,2,1,0,11 3,,1,1,[No response],,0,0,0,0,0,0 3,,1,2,"cat: amphibian",amphibian,8,1,2,1,1,13 3,,1,2,cat: mammal,mammal,0,1,1,2,0,4 3,,1,2,"cat: insect",insect,4,4,2,2,0,12 3,,1,2,[No response],,0,0,0,0,0,0 3,,1,3,"newt: amphibian",amphibian,6,4,4,3,1,18 3,,1,3,"newt: mammal",mammal,3,0,1,2,0,6 3,,1,3,newt: insect,insect,3,2,0,0,0,5 3,,1,3,[No response],,0,0,0,0,0,0 4,,1,1,False,,3,0,0,0,0,3 4,,1,1,True,,9,0,0,0,0,9 4,,1,1,[No response],,0,0,0,0,0,0 PK9B\For each of the following questions, select the matching answer from the menu.

                ",1,,,1,0.1,randomsamatch,1,localhost+080922073724+qF803I,localhost+080922075820+zbZtaD,0,1222069044,1222070300,2,2,1 14,22,14,5,0,Is Thai difficult?,Is Thai difficult?,0,,,1,0.1,shortanswer,1,localhost+080922073655+2FLtCU,localhost+080922073655+fgUeOj,0,1222069015,0,2,NULL,1 15,23,15,5,0,Is Thai grammar difficult?,Is Thai grammar difficult?,0,,,1,0.1,shortanswer,1,localhost+080922073655+LYSD32,localhost+080922073655+WgRYk4,0,1222069015,0,2,NULL,1 16,24,16,5,0,Is Thai pronunciation difficult?,Is Thai pronunciation difficult?,0,,,1,0.1,shortanswer,1,localhost+080922073655+5p1w22,localhost+080922073655+g5jrXa,0,1222069015,0,2,NULL,1 17,20,17,5,0,Who's buried in Grant's tomb?,Who's buried in Grant's tomb?,0,,,1,0.1,shortanswer,1,localhost+080922073655+PTDcDZ,localhost+080922073655+aghyfu,0,1222069015,0,2,NULL,1 18,21,18,5,0,Who's buried in Jamie's tomb?,Who's buried in Jamie's tomb?,0,,,1,0.1,shortanswer,1,localhost+080922073655+Xvy1ns,localhost+080922073655+Mx0Izs,0,1222069015,0,2,NULL,1 19,18,19,5,0,questiontruefalse-7,This question is really stupid,0,,Well done,1,1,truefalse,1,localhost+080922073527+9bzTef,localhost+080922073527+hjcQR1,0,1222068927,0,2,NULL,1 20,19,20,5,0,questiontruefalse-9,This question is really stupid,0,,Well done,1,1,truefalse,1,localhost+080922073527+TI0yD4,localhost+080922073527+iXIulQ,0,1222068927,0,2,NULL,1 PK9B\FV^^5report/statistics/tests/fixtures/responsecounts03.csvnu[slot,variant,modelresponse,actualresponse,totalcount 1,4,"{a} + {b} (±0.01 Relative)",19.4,1 PK9B\-VUU,report/statistics/tests/fixtures/steps02.csvnu[quizattempt,firstname,lastname,responses.1.-submit,responses.1.-tryagain,responses.1.cat,responses.1.frog,responses.1.newt,finished 1,John,Jones,1,,insect,insect,insect,0 1,,,,1,insect,insect,insect,0 1,,,1,,insect,insect,insect,0 1,,,,1,insect,insect,insect,0 1,,,1,,insect,insect,insect,0 1,,,,1,insect,insect,insect,0 1,,,1,,insect,insect,insect,0 1,,,,1,insect,insect,insect,0 1,,,1,,insect,insect,insect,1 2,Mark,Jones,1,,amphibian,insect,insect,0 2,,,,1,amphibian,insect,insect,0 2,,,1,,insect,insect,insect,0 2,,,,1,insect,insect,insect,0 2,,,1,,insect,mammal,insect,0 2,,,,1,insect,mammal,insect,0 2,,,1,,insect,mammal,insect,0 2,,,,1,insect,mammal,insect,0 2,,,1,,mammal,mammal,insect,0 2,,,,1,mammal,mammal,insect,0 2,,,1,,mammal,mammal,amphibian,1 3,Michael,Jackson,1,,insect,insect,insect,0 3,,,,1,insect,insect,insect,0 3,,,1,,insect,insect,insect,0 3,,,,1,insect,insect,insect,0 3,,,1,,insect,insect,insect,0 3,,,,1,insect,insect,insect,0 3,,,1,,insect,insect,insect,0 3,,,,1,insect,insect,insect,0 3,,,1,,insect,insect,insect,0 3,,,,1,insect,insect,insect,0 3,,,1,,mammal,amphibian,amphibian,1 PK9B\&  ,report/statistics/tests/fixtures/steps00.csvnu[quizattempt,firstname,lastname,randqs.1,responses.1.answer,variants.2,responses.2.answer,responses.3.cat,responses.3.frog,responses.3.newt,responses.4.answer,responses.5.Four,responses.5.One,responses.5.Three,responses.5.Two,responses.6.answer,responses.7.1.answer,responses.7.2.answer 1,John,Jones,numerical,3.14,1,9.9,mammal,amphibian,amphibian,1,,1,1,,Two,Owl,Pussy-cat 2,John,Smith,shortanswer,frog,1,9.9,mammal,amphibian,amphibian,1,,,1,1,One,Dog,Bow-wow 3,John,Vicars,numerical,3.14,6,9.4,mammal,amphibian,amphibian,1,1,,1,,Two,Owl,Pussy-cat 4,John,Pacino,shortanswer,butterfly,6,-0.1,mammal,amphibian,amphibian,1,,1,1,,Four,Dog,Bow-wow 5,John,Deniro,numerical,3.14,4,0,mammal,amphibian,amphibian,0,1,,,1,One,Dog,Pussy-cat 6,John,Banks,numerical,3.14,1,9.9,mammal,amphibian,amphibian,1,1,1,,,Two,Owl,"Wiggly worm" 7,John,Asimov,numerical,3.142,7,9.1,mammal,amphibian,amphibian,1,1,,1,,One,Owl,"Wiggly worm" 8,John,Chomsky,numerical,3.14,4,19.4,mammal,amphibian,amphibian,0,1,,1,,One,Owl,"Wiggly worm" 9,John,Yamaguchi,shortanswer,frog,1,-0.7,mammal,amphibian,amphibian,1,,1,1,,One,Owl,Pussy-cat 10,John,Robbins,numerical,3.1,5,14.2,mammal,amphibian,amphibian,0,,,1,1,One,Owl,Bow-wow 11,Joe,Jones,shortanswer,toad,6,-0.2,mammal,amphibian,amphibian,1,,1,,1,One,Owl,Bow-wow 12,Joe,Smith,shortanswer,frog,8,5.7,mammal,amphibian,amphibian,0,1,,1,,One,Owl,Pussy-cat 13,Joe,Vicars,numerical,3.14,8,-0.2,mammal,amphibian,amphibian,1,1,1,,,One,wfz9p,Pussy-cat 14,Joe,Pacino,shortanswer,frog,1,-0.2,mammal,amphibian,amphibian,1,,1,1,,Two,Owl,Bow-wow 15,Joe,Deniro,numerical,3.1,7,-0.9,mammal,amphibian,amphibian,1,1,,,1,One,Owl,Pussy-cat 16,Joe,Banks,shortanswer,frog,10,-0.7,mammal,amphibian,amphibian,0,1,1,,,One,Owl,Pussy-cat 17,Joe,Asimov,shortanswer,toad,4,-0.4,mammal,amphibian,amphibian,1,,,1,1,Three,Owl,Pussy-cat 18,Joe,Chomsky,shortanswer,frog,6,-1,mammal,amphibian,amphibian,1,1,,,1,One,Pussy-cat,Pussy-cat 19,Joe,Yamaguchi,numerical,3.14,8,-0.5,mammal,amphibian,amphibian,1,,1,,1,Two,Owl,Bow-wow 20,Joe,Robbins,shortanswer,butterfly,4,19.4,mammal,amphibian,amphibian,0,,,1,1,One,"Wiggly worm",Bow-wow 21,Roberto,Jones,shortanswer,frog,8,5.7,amphibian,amphibian,mammal,1,,,1,1,Three,RjUpn,Pussy-cat 22,Roberto,Smith,shortanswer,frog,5,14.2,mammal,amphibian,amphibian,0,1,1,,,Two,Dog,Pussy-cat 23,Roberto,Vicars,shortanswer,frog,5,14.2,mammal,amphibian,amphibian,1,1,,,1,One,"Wiggly worm",Pussy-cat 24,Roberto,Pacino,numerical,3.1,1,-1,mammal,amphibian,amphibian,0,,1,,1,One,Owl,Pussy-cat 25,Roberto,Deniro,numerical,3.1,8,-0.1,mammal,amphibian,amphibian,1,,1,1,,One,Owl,Pussy-cat PK9B\FF0report/statistics/tests/fixtures/questions03.csvnu[slot,type,which,cat,mark 1,calculatedsimple,sumwithvariants,maincat,1 PK9B\5report/statistics/tests/fixtures/responsecounts00.csvnu[slot,randq,variant,subpart,modelresponse,actualresponse,totalcount 1,numerical,1,1,"3.142 (3.142..3.142)",3.142,1 1,numerical,1,1,"3.14 (3.14..3.14)",3.14,7 1,numerical,1,1,"3.1 (3.1..3.1)",3.1,4 1,shortanswer,1,1,frog,frog,9 1,shortanswer,1,1,*,butterfly,2 1,shortanswer,1,1,toad,toad,2 2,,1,1,{a} + {b} (±0.01 Relative),9.9,3 2,,1,1,[NO MATCH],-0.7,1 2,,1,1,[NO MATCH],-0.2,1 2,,1,1,[NO MATCH],-1,1 2,,4,1,{a} + {b} (±0.01 Relative),19.4,2 2,,4,1,[NO MATCH],0,1 2,,4,1,[NO MATCH],-0.4,1 3,,1,1,frog: amphibian,amphibian,25 3,,1,2,cat: mammal,mammal,24 3,,1,2,cat: amphibian,amphibian,1 3,,1,3,newt: amphibian,amphibian,24 3,,1,3,newt: mammal,mammal,1 PK9B\ؑQQ,report/statistics/tests/fixtures/steps03.csvnu[quizattempt,firstname,lastname,responses.1.answer,variants.1 1,John,Jones,19.4,4 PK9B\x OOO5report/statistics/tests/quiz_attempt_deleted_test.phpnu[. namespace quiz_statistics; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->dirroot . '/mod/quiz/tests/quiz_question_helper_test_trait.php'); use core\task\manager; use quiz_statistics\task\recalculate; use quiz_statistics\tests\statistics_helper; use quiz_statistics\tests\statistics_test_trait; /** * Unit tests for attempt_deleted observer * * @package quiz_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 * @covers \quiz_statistics\hook_callbacks::quiz_attempt_submitted_or_deleted */ final class quiz_attempt_deleted_test extends \advanced_testcase { use \quiz_question_helper_test_trait; use statistics_test_trait; /** * Deleting an attempt should queue the recalculation task for that quiz in 1 hour's time. * * @return void */ public function test_queue_task_on_deletion(): void { [$user, $quiz] = $this->create_test_data(); $this->attempt_quiz($quiz, $user); [, , $attempt] = $this->attempt_quiz($quiz, $user, 2); statistics_helper::run_pending_recalculation_tasks(true); $tasks = manager::get_adhoc_tasks(recalculate::class); $this->assertEmpty($tasks); quiz_delete_attempt($attempt->get_attemptid(), $quiz); $tasks = manager::get_adhoc_tasks(recalculate::class); $this->assertCount(1, $tasks); $task = reset($tasks); $this->assert_task_is_queued_for_quiz($task, $quiz); } /** * Deleting multiple attempts of the same quiz should only queue one instance of the task. * * @return void */ public function test_queue_single_task_for_multiple_deletions(): void { [$user1, $quiz] = $this->create_test_data(); $user2 = $this->getDataGenerator()->create_user(); $this->attempt_quiz($quiz, $user1); [, , $attempt1] = $this->attempt_quiz($quiz, $user1, 2); $this->attempt_quiz($quiz, $user2); [, , $attempt2] = $this->attempt_quiz($quiz, $user2, 2); statistics_helper::run_pending_recalculation_tasks(true); $tasks = manager::get_adhoc_tasks(recalculate::class); $this->assertEmpty($tasks); quiz_delete_attempt($attempt1->get_attemptid(), $quiz); quiz_delete_attempt($attempt2->get_attemptid(), $quiz); $tasks = manager::get_adhoc_tasks(recalculate::class); $this->assertCount(1, $tasks); $task = reset($tasks); $this->assert_task_is_queued_for_quiz($task, $quiz); } /** * Deleting another attempt after processing the task should queue a new task. * * @return void */ public function test_queue_new_task_after_processing(): void { [$user1, $quiz, $course] = $this->create_test_data(); $user2 = $this->getDataGenerator()->create_user(); $this->attempt_quiz($quiz, $user1); [, , $attempt1] = $this->attempt_quiz($quiz, $user1, 2); $this->attempt_quiz($quiz, $user2); [, , $attempt2] = $this->attempt_quiz($quiz, $user2, 2); statistics_helper::run_pending_recalculation_tasks(true); $tasks = manager::get_adhoc_tasks(recalculate::class); $this->assertEmpty($tasks); quiz_delete_attempt($attempt1->get_attemptid(), $quiz); $tasks = manager::get_adhoc_tasks(recalculate::class); $this->assertCount(1, $tasks); $this->expectOutputRegex("~Re-calculating statistics for quiz {$quiz->name} \({$quiz->id}\) " . "from course {$course->shortname} \({$course->id}\) with 3 attempts~"); statistics_helper::run_pending_recalculation_tasks(); $tasks = manager::get_adhoc_tasks(recalculate::class); $this->assertEmpty($tasks); quiz_delete_attempt($attempt2->get_attemptid(), $quiz); $tasks = manager::get_adhoc_tasks(recalculate::class); $this->assertCount(1, $tasks); $task = reset($tasks); $this->assert_task_is_queued_for_quiz($task, $quiz); } /** * Deleting attempts from different quizzes will queue a task for each. * * @return void */ public function test_queue_separate_tasks_for_multiple_quizzes(): void { [$user1, $quiz1] = $this->create_test_data(); [$user2, $quiz2] = $this->create_test_data(); $this->attempt_quiz($quiz1, $user1); [, , $attempt1] = $this->attempt_quiz($quiz1, $user1, 2); $this->attempt_quiz($quiz2, $user2); [, , $attempt2] = $this->attempt_quiz($quiz2, $user2, 2); statistics_helper::run_pending_recalculation_tasks(true); $tasks = manager::get_adhoc_tasks(recalculate::class); $this->assertEmpty($tasks); quiz_delete_attempt($attempt1->get_attemptid(), $quiz1); quiz_delete_attempt($attempt2->get_attemptid(), $quiz2); $tasks = manager::get_adhoc_tasks(recalculate::class); $this->assertCount(2, $tasks); $task1 = array_shift($tasks); $this->assert_task_is_queued_for_quiz($task1, $quiz1); $task2 = array_shift($tasks); $this->assert_task_is_queued_for_quiz($task2, $quiz2); } } PK9B\i)7report/statistics/tests/quiz_attempt_submitted_test.phpnu[. namespace quiz_statistics; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->dirroot . '/mod/quiz/tests/quiz_question_helper_test_trait.php'); use core\task\manager; use quiz_statistics\task\recalculate; use quiz_statistics\tests\statistics_helper; use quiz_statistics\tests\statistics_test_trait; /** * Unit tests for attempt_submitted observer * * @package quiz_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 * @covers \quiz_statistics\hook_callbacks::quiz_attempt_submitted_or_deleted */ final class quiz_attempt_submitted_test extends \advanced_testcase { use \quiz_question_helper_test_trait; use statistics_test_trait; /** * Attempting a quiz should queue the recalculation task for that quiz in 1 hour's time. * * @return void */ public function test_queue_task_on_submission(): void { [$user, $quiz] = $this->create_test_data(); $tasks = manager::get_adhoc_tasks(recalculate::class); $this->assertEmpty($tasks); $this->attempt_quiz($quiz, $user); $tasks = manager::get_adhoc_tasks(recalculate::class); $this->assertCount(1, $tasks); $task = reset($tasks); $this->assert_task_is_queued_for_quiz($task, $quiz); } /** * Attempting a quiz multiple times should only queue one instance of the task. * * @return void */ public function test_queue_single_task_for_multiple_submissions(): void { [$user1, $quiz] = $this->create_test_data(); $user2 = $this->getDataGenerator()->create_user(); $tasks = manager::get_adhoc_tasks(recalculate::class); $this->assertEmpty($tasks); $this->attempt_quiz($quiz, $user1); $this->attempt_quiz($quiz, $user2); $tasks = manager::get_adhoc_tasks(recalculate::class); $this->assertCount(1, $tasks); $task = reset($tasks); $this->assert_task_is_queued_for_quiz($task, $quiz); } /** * Attempting the quiz again after processing the task should queue a new task. * * @return void */ public function test_queue_new_task_after_processing(): void { [$user1, $quiz, $course] = $this->create_test_data(); $user2 = $this->getDataGenerator()->create_user(); $tasks = manager::get_adhoc_tasks(recalculate::class); $this->assertEmpty($tasks); $this->attempt_quiz($quiz, $user1); $tasks = manager::get_adhoc_tasks(recalculate::class); $this->assertCount(1, $tasks); $this->expectOutputRegex("~Re-calculating statistics for quiz {$quiz->name} \({$quiz->id}\) " . "from course {$course->shortname} \({$course->id}\) with 1 attempts~"); statistics_helper::run_pending_recalculation_tasks(); $tasks = manager::get_adhoc_tasks(recalculate::class); $this->assertEmpty($tasks); $this->attempt_quiz($quiz, $user2); $tasks = manager::get_adhoc_tasks(recalculate::class); $this->assertCount(1, $tasks); $task = reset($tasks); $this->assert_task_is_queued_for_quiz($task, $quiz); } /** * Attempting different quizzes will queue a task for each. * * @return void */ public function test_queue_separate_tasks_for_multiple_quizzes(): void { [$user1, $quiz1] = $this->create_test_data(); [$user2, $quiz2] = $this->create_test_data(); $tasks = manager::get_adhoc_tasks(recalculate::class); $this->assertEmpty($tasks); $this->attempt_quiz($quiz1, $user1); $this->attempt_quiz($quiz2, $user2); $tasks = manager::get_adhoc_tasks(recalculate::class); $this->assertCount(2, $tasks); $task1 = array_shift($tasks); $this->assert_task_is_queued_for_quiz($task1, $quiz1); $task2 = array_shift($tasks); $this->assert_task_is_queued_for_quiz($task2, $quiz2); } } PK9B\Rz2 1report/statistics/tests/statistics_table_test.phpnu[. namespace quiz_statistics; use quiz_statistics_table; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->dirroot . '/mod/quiz/report/statistics/statistics_table.php'); /** * Class quiz_statistics_statistics_table_testcase * * @package quiz_statistics * @category test * @copyright 2018 Shamim Rezaie * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class statistics_table_test extends \advanced_testcase { public function test_format_percentage(): void { $table = new quiz_statistics_table(); // The format_percentage method is protected. Use Reflection to call the method. $reflector = new \ReflectionClass('quiz_statistics_table'); $method = $reflector->getMethod('format_percentage'); $this->assertEquals( '84.758%', $method->invokeArgs($table, [0.847576, true, 3]) ); $this->assertEquals( '84.758%', $method->invokeArgs($table, [84.7576, false, 3]) ); } public function test_format_percentage_range(): void { $table = new quiz_statistics_table(); // The format_percentage_range method is protected. Use Reflection to call the method. $reflector = new \ReflectionClass('quiz_statistics_table'); $method = $reflector->getMethod('format_percentage_range'); $this->assertEquals( '54.400% − 84.758%', $method->invokeArgs($table, [0.544, 0.847576, true, 3]) ); $this->assertEquals( '54.400% − 84.758%', $method->invokeArgs($table, [54.4, 84.7576, false, 3]) ); } public function test_format_range(): void { $table = new quiz_statistics_table(); // The format_range method is protected. Use Reflection to call the method. $reflector = new \ReflectionClass('quiz_statistics_table'); $method = $reflector->getMethod('format_range'); $this->assertEquals( '5 − 10', $method->invokeArgs($table, [5, 10]) ); $this->assertEquals( 'Some Text − 10', $method->invokeArgs($table, ['Some Text', 10]) ); } } PK9B\b-3L3L=report/statistics/tests/stats_from_steps_walkthrough_test.phpnu[. namespace quiz_statistics; use question_attempt; use question_bank; use question_finder; use quiz_statistics_report; /** * Quiz attempt walk through using data from csv file. * * The quiz stats below and the question stats found in qstats00.csv were calculated independently in a spreadsheet which is * available in open document or excel format here : * https://github.com/jamiepratt/moodle-quiz-tools/tree/master/statsspreadsheet * * Similarly the question variant's stats in qstats00.csv are calculated in stats_for_variant_1.xls and stats_for_variant_8.xls * The calculations in the spreadsheets are the same as for the other question stats but applied just to the attempts where the * variants appeared. * * @package quiz_statistics * @category test * @copyright 2013 The Open University * @author Jamie Pratt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class stats_from_steps_walkthrough_test extends \mod_quiz\tests\attempt_walkthrough_testcase { /** * @var quiz_statistics_report object to do stats calculations. */ protected $report; #[\Override] public static function setUpBeforeClass(): void { global $CFG; parent::setUpBeforeClass(); require_once($CFG->dirroot . '/mod/quiz/report/statistics/report.php'); require_once($CFG->dirroot . '/mod/quiz/report/reportlib.php'); } #[\Override] protected static function get_test_files(): array { return ['questions', 'steps', 'results', 'qstats', 'responsecounts']; } /** * Create a quiz add questions to it, walk through quiz attempts and then check results. * * @param array $csvdata data read from csv file "questionsXX.csv", "stepsXX.csv" and "resultsXX.csv". * // phpcs:ignore moodle.PHPUnit.TestCaseProvider.dataProviderSyntaxMethodNotFound * @dataProvider get_data_for_walkthrough */ public function test_walkthrough_from_csv($quizsettings, $csvdata): void { $this->create_quiz_simulate_attempts_and_check_results($quizsettings, $csvdata); $whichattempts = QUIZ_GRADEAVERAGE; // All attempts. $whichtries = question_attempt::ALL_TRIES; $groupstudentsjoins = new \core\dml\sql_join(); [$questions, $quizstats, $questionstats, $qubaids] = $this->check_stats_calculations_and_response_analysis( $csvdata, $whichattempts, $whichtries, $groupstudentsjoins ); if ($quizsettings['testnumber'] === '00') { $this->check_variants_count_for_quiz_00($questions, $questionstats, $whichtries, $qubaids); $this->check_quiz_stats_for_quiz_00($quizstats); } } /** * Check actual question stats are the same as that found in csv file. * * @param $qstats array data from csv file. * @param $questionstats \core_question\statistics\questions\all_calculated_for_qubaid_condition Calculated stats. */ protected function check_question_stats($qstats, $questionstats) { foreach ($qstats as $slotqstats) { foreach ($slotqstats as $statname => $slotqstat) { if (!in_array($statname, ['slot', 'subqname']) && $slotqstat !== '') { $this->assert_stat_equals( $slotqstat, $questionstats, $slotqstats['slot'], $slotqstats['subqname'], $slotqstats['variant'], $statname ); } } // Check that sub-question boolean field is correctly set. $this->assert_stat_equals( !empty($slotqstats['subqname']), $questionstats, $slotqstats['slot'], $slotqstats['subqname'], $slotqstats['variant'], 'subquestion' ); } } /** * Check that the stat is as expected within a reasonable tolerance. * * @param float|string|bool $expected expected value of stat. * @param \core_question\statistics\questions\all_calculated_for_qubaid_condition $questionstats * @param int $slot * @param string $subqname if empty string then not an item stat. * @param int|string $variant if empty string then not a variantstat. * @param string $statname */ protected function assert_stat_equals($expected, $questionstats, $slot, $subqname, $variant, $statname) { if ($variant === '' && $subqname === '') { $actual = $questionstats->for_slot($slot)->{$statname}; } else if ($subqname !== '') { $actual = $questionstats->for_subq($this->randqids[$slot][$subqname])->{$statname}; } else { $actual = $questionstats->for_slot($slot, $variant)->{$statname}; } $message = "$statname for slot $slot"; if ($expected === '**NULL**') { $this->assertEquals(null, $actual, $message); } else if (is_bool($expected)) { $this->assertEquals($expected, $actual, $message); } else if (is_numeric($expected)) { switch ($statname) { case 'covariance': case 'discriminationindex': case 'discriminativeefficiency': case 'effectiveweight': $precision = 1e-5; break; default: $precision = 1e-6; } $delta = abs($expected) * $precision; $this->assertEqualsWithDelta((float)$expected, $actual, $delta, $message); } else { $this->assertEquals($expected, $actual, $message); } } /** * Assertion helper to check that response counts are as expected. * * @param $question * @param $qubaids * @param $expected * @param $whichtries */ protected function assert_response_count_equals($question, $qubaids, $expected, $whichtries): void { $responesstats = new \core_question\statistics\responses\analyser($question); $analysis = $responesstats->load_cached($qubaids, $whichtries); if (!isset($expected['subpart'])) { $subpart = 1; } else { $subpart = $expected['subpart']; } [$subpartid, $responseclassid] = $this->get_response_subpart_and_class_id( $question, $subpart, $expected['modelresponse'] ); $subpartanalysis = $analysis->get_analysis_for_subpart($expected['variant'], $subpartid); $responseclassanalysis = $subpartanalysis->get_response_class($responseclassid); $actualresponsecounts = $responseclassanalysis->data_for_question_response_table('', ''); foreach ($actualresponsecounts as $actualresponsecount) { if ($actualresponsecount->response == $expected['actualresponse'] || count($actualresponsecounts) == 1) { $i = 1; $partofanalysis = " slot {$expected['slot']}, rand q '{$expected['randq']}', variant {$expected['variant']}, " . "for expected model response {$expected['modelresponse']}, " . "actual response {$expected['actualresponse']}"; while (isset($expected['count' . $i])) { if ($expected['count' . $i] != 0) { $this->assertTrue( isset($actualresponsecount->trycount[$i]), "There is no count at all for try $i on " . $partofanalysis ); $this->assertEquals( $expected['count' . $i], $actualresponsecount->trycount[$i], "Count for try $i on " . $partofanalysis ); } $i++; } if (isset($expected['totalcount'])) { $this->assertEquals( $expected['totalcount'], $actualresponsecount->totalcount, "Total count on " . $partofanalysis ); } return; } } throw new \coding_exception("Expected response '{$expected['actualresponse']}' not found."); } /** * Get the subpart id and response class id for a given subpart and model response. * * @param $question * @param $subpart * @param $modelresponse * @return array */ protected function get_response_subpart_and_class_id($question, $subpart, $modelresponse): array { $qtypeobj = question_bank::get_qtype($question->qtype, false); $possibleresponses = $qtypeobj->get_possible_responses($question); $possibleresponsesubpartids = array_keys($possibleresponses); if (!isset($possibleresponsesubpartids[$subpart - 1])) { throw new \coding_exception("Subpart '{$subpart}' not found."); } $subpartid = $possibleresponsesubpartids[$subpart - 1]; if ($modelresponse == '[NO RESPONSE]') { return [$subpartid, null]; } else if ($modelresponse == '[NO MATCH]') { return [$subpartid, 0]; } $modelresponses = []; foreach ($possibleresponses[$subpartid] as $responseclassid => $subpartpossibleresponse) { $modelresponses[$responseclassid] = $subpartpossibleresponse->responseclass; } $this->assertContains($modelresponse, $modelresponses); $responseclassid = array_search($modelresponse, $modelresponses); return [$subpartid, $responseclassid]; } /** * Assertion helper to check that response counts are as expected. * * @param $responsecounts * @param $qubaids * @param $questions * @param $whichtries */ protected function check_response_counts($responsecounts, $qubaids, $questions, $whichtries) { foreach ($responsecounts as $expected) { $defaultsforexpected = ['randq' => '', 'variant' => '1', 'subpart' => '1']; foreach ($defaultsforexpected as $key => $expecteddefault) { if (!isset($expected[$key])) { $expected[$key] = $expecteddefault; } } if ($expected['randq'] == '') { $question = $questions[$expected['slot']]; } else { $qid = $this->randqids[$expected['slot']][$expected['randq']]; $question = question_finder::get_instance()->load_question_data($qid); } $this->assert_response_count_equals($question, $qubaids, $expected, $whichtries); } } /** * Assertion helper to check that variant counts are as expected for quiz 00. * * @param $questions * @param $questionstats * @param $whichtries * @param $qubaids */ protected function check_variants_count_for_quiz_00($questions, $questionstats, $whichtries, $qubaids) { $expectedvariantcounts = [2 => [1 => 6, 4 => 4, 5 => 3, 6 => 4, 7 => 2, 8 => 5, 10 => 1]]; foreach ($questions as $slot => $question) { if (!question_bank::get_qtype($question->qtype, false)->can_analyse_responses()) { continue; } $responesstats = new \core_question\statistics\responses\analyser($question); $this->assertTimeCurrent($responesstats->get_last_analysed_time($qubaids, $whichtries)); $analysis = $responesstats->load_cached($qubaids, $whichtries); $variantsnos = $analysis->get_variant_nos(); if (isset($expectedvariantcounts[$slot])) { // Compare contents, ignore ordering of array, using canonicalize parameter of assertEquals. $this->assertEqualsCanonicalizing(array_keys($expectedvariantcounts[$slot]), $variantsnos); } else { $this->assertEquals([1], $variantsnos); } $totalspervariantno = []; foreach ($variantsnos as $variantno) { $subpartids = $analysis->get_subpart_ids($variantno); foreach ($subpartids as $subpartid) { if (!isset($totalspervariantno[$subpartid])) { $totalspervariantno[$subpartid] = []; } $totalspervariantno[$subpartid][$variantno] = 0; $subpartanalysis = $analysis->get_analysis_for_subpart($variantno, $subpartid); $classids = $subpartanalysis->get_response_class_ids(); foreach ($classids as $classid) { $classanalysis = $subpartanalysis->get_response_class($classid); $actualresponsecounts = $classanalysis->data_for_question_response_table('', ''); foreach ($actualresponsecounts as $actualresponsecount) { $totalspervariantno[$subpartid][$variantno] += $actualresponsecount->totalcount; } } } } // Count all counted responses for each part of question and confirm that counted responses, for most question types // are the number of attempts at the question for each question part. if ($slot != 5) { // Slot 5 holds a multi-choice multiple question. // Multi-choice multiple is slightly strange. Actual answer counts given for each sub part do not add up to the // total attempt count. // This is because each option is counted as a sub part and each option can be off or on in each attempt. Off is // not counted in response analysis for this question type. foreach ($totalspervariantno as $totalpervariantno) { if (isset($expectedvariantcounts[$slot])) { // If we know how many attempts there are at each variant we can check // that we have counted the correct amount of responses for each variant. $this->assertEqualsCanonicalizing( $expectedvariantcounts[$slot], $totalpervariantno, "Totals responses do not add up in response analysis for slot {$slot}." ); } else { $this->assertEquals( 25, array_sum($totalpervariantno), "Totals responses do not add up in response analysis for slot {$slot}." ); } } } } foreach ($expectedvariantcounts as $slot => $expectedvariantcount) { foreach ($expectedvariantcount as $variantno => $s) { $this->assertEquals($s, $questionstats->for_slot($slot, $variantno)->s); } } } /** * Assertion helper to ensure that quiz states are as expected. * * @param $quizstats */ protected function check_quiz_stats_for_quiz_00($quizstats) { $quizstatsexpected = [ 'median' => 4.5, 'firstattemptsavg' => 4.617333332, 'allattemptsavg' => 4.617333332, 'firstattemptscount' => 25, 'allattemptscount' => 25, 'standarddeviation' => 0.8117265554, 'skewness' => -0.092502502, 'kurtosis' => -0.7073968557, 'cic' => -87.2230935542, 'errorratio' => 136.8294900795, 'standarderror' => 1.1106813066, ]; foreach ($quizstatsexpected as $statname => $statvalue) { $this->assertEqualsWithDelta($statvalue, $quizstats->$statname, abs($statvalue) * 1.5e-5, $quizstats->$statname); } } /** * Check the question stats and the response counts used in the statistics report. If the appropriate files exist in fixtures/. * * @param array $csvdata Data loaded from csv files for this test. * @param string $whichattempts * @param string $whichtries * @param \core\dml\sql_join $groupstudentsjoins * @return array with contents 0 => $questions, 1 => $quizstats, 2 => $questionstats, 3 => $qubaids Might be needed for further * testing. */ protected function check_stats_calculations_and_response_analysis( $csvdata, $whichattempts, $whichtries, \core\dml\sql_join $groupstudentsjoins ) { $this->report = new quiz_statistics_report(); $questions = $this->report->load_and_initialise_questions_for_calculations($this->quiz); [$quizstats, $questionstats] = $this->report->get_all_stats_and_analysis( $this->quiz, $whichattempts, $whichtries, $groupstudentsjoins, $questions ); $qubaids = quiz_statistics_qubaids_condition($this->quiz->id, $groupstudentsjoins, $whichattempts); // We will create some quiz and question stat calculator instances and some response analyser instances, just in order // to check the last analysed time then returned. $quizcalc = new calculator(); // Should not be a delay of more than one second between the calculation of stats above and here. $this->assertTimeCurrent($quizcalc->get_last_calculated_time($qubaids)); $qcalc = new \core_question\statistics\questions\calculator($questions); $this->assertTimeCurrent($qcalc->get_last_calculated_time($qubaids)); if (isset($csvdata['responsecounts'])) { $this->check_response_counts($csvdata['responsecounts'], $qubaids, $questions, $whichtries); } if (isset($csvdata['qstats'])) { $this->check_question_stats($csvdata['qstats'], $questionstats); return [$questions, $quizstats, $questionstats, $qubaids]; } return [$questions, $quizstats, $questionstats, $qubaids]; } } PK9B\ɽ[[+report/statistics/tests/statistics_test.phpnu[. /** * Unit tests for (some of) /question/engine/statistics.php * * @package quiz_statistics * @category test * @copyright 2008 Jamie Pratt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace quiz_statistics; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->libdir . '/questionlib.php'); require_once($CFG->dirroot . '/mod/quiz/locallib.php'); require_once($CFG->dirroot . '/mod/quiz/report/reportlib.php'); class testable_all_calculated_for_qubaid_condition extends \core_question\statistics\questions\all_calculated_for_qubaid_condition { /** * Disabling caching in tests so we are always sure to force the calculation of stats right then and there. * * @param qubaid_condition $qubaids */ public function cache($qubaids) { } } /** * Test helper subclass of question_statistics * * @copyright 2010 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class testable_question_statistics extends \core_question\statistics\questions\calculator { /** * @var stdClass[] */ protected $lateststeps; protected $statscollectionclassname = '\quiz_statistics\testable_all_calculated_for_qubaid_condition'; public function set_step_data($states) { $this->lateststeps = $states; } protected function get_random_guess_score($questiondata) { return 0; } /** * @param $qubaids qubaid_condition is ignored in this test * @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) { $summarks = []; $fakeusageid = 0; foreach ($this->lateststeps as $step) { // The same 'sumgrades' field is available in step data for every slot, we will ignore all slots but slot 1. // The step for slot 1 is always the first one in the csv file for each usage, we will use that to separate steps from // each usage. if ($step->slot == 1) { $fakeusageid++; $summarks[$fakeusageid] = $step->sumgrades; } unset($step->sumgrades); $step->questionusageid = $fakeusageid; } return [$this->lateststeps, $summarks]; } protected function cache_stats($qubaids) { // No caching wanted for tests. } } /** * Unit tests for (some of) question_statistics. * * @copyright 2008 Jamie Pratt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class statistics_test extends \basic_testcase { /** @var testable_all_calculated_for_qubaid_condition object created to test class. */ protected $qstats; public function test_qstats(): void { global $CFG; // Data is taken from randomly generated attempts data generated by // contrib/tools/generators/qagenerator/. $steps = $this->get_records_from_csv(__DIR__.'/fixtures/mdl_question_states.csv'); // Data is taken from questions mostly generated by // contrib/tools/generators/generator.php. $questions = $this->get_records_from_csv(__DIR__.'/fixtures/mdl_question.csv'); $calculator = new testable_question_statistics($questions); $calculator->set_step_data($steps); $this->qstats = $calculator->calculate(null); // Values expected are taken from contrib/tools/quiz_tools/stats.xls. $facility = [0, 0, 0, 0, null, null, null, 41.19318182, 81.36363636, 71.36363636, 65.45454545, 65.90909091, 36.36363636, 59.09090909, 50, 59.09090909, 63.63636364, 45.45454545, 27.27272727, 50]; $this->qstats_q_fields('facility', $facility, 100); $sd = [0, 0, 0, 0, null, null, null, 1912.733589, 251.2738111, 322.6312277, 333.4199022, 337.5811591, 492.3659639, 503.2362797, 511.7663157, 503.2362797, 492.3659639, 509.6471914, 455.8423058, 511.7663157]; $this->qstats_q_fields('sd', $sd, 1000); $effectiveweight = [0, 0, 0, 0, 0, 0, 0, 26.58464457, 3.368456046, 3.253955259, 7.584083694, 3.79658376, 3.183278505, 4.532356904, 7.78856243, 10.08351572, 8.381139345, 8.727645713, 7.946277111, 4.769500946]; $this->qstats_q_fields('effectiveweight', $effectiveweight); $discriminationindex = [null, null, null, null, null, null, null, 25.88327077, 1.170256965, -4.207816809, 28.16930644, -2.513606859, -12.99017581, -8.900638238, 8.670004606, 29.63337745, 15.18945843, 16.21079629, 15.52451404, -8.396734802]; $this->qstats_q_fields('discriminationindex', $discriminationindex); $discriminativeefficiency = [null, null, null, null, null, null, null, 27.23492723, 1.382386552, -4.691171307, 31.12404354, -2.877487579, -17.5074184, -10.27568922, 10.86956522, 34.58997279, 17.4790556, 20.14359793, 22.06477733, -10]; $this->qstats_q_fields('discriminativeefficiency', $discriminativeefficiency); } public function qstats_q_fields($fieldname, $values, $multiplier=1) { foreach ($this->qstats->get_all_slots() as $slot) { $value = array_shift($values); if ($value !== null) { $this->assertEqualsWithDelta($value, $this->qstats->for_slot($slot)->{$fieldname} * $multiplier, 1E-6); } else { $this->assertEquals($value, $this->qstats->for_slot($slot)->{$fieldname} * $multiplier); } } } public function get_fields_from_csv($line) { $line = trim($line); $items = preg_split('!,!', $line); $cnt = count($items); for ($key = 0; $key < $cnt; $key++) { if ($items[$key]!='') { if ($start = ($items[$key][0]=='"')) { $items[$key] = substr($items[$key], 1); while (!$end = ($items[$key][strlen($items[$key])-1]=='"')) { $item = $items[$key]; unset($items[$key]); $key++; $items[$key] = $item . ',' . $items[$key]; } $items[$key] = substr($items[$key], 0, strlen($items[$key])-1); } } } return $items; } public function get_records_from_csv($filename) { $filecontents = file($filename, FILE_IGNORE_NEW_LINES); $records = []; // Skip the first line containing field names. $keys = $this->get_fields_from_csv(array_shift($filecontents)); while (null !== ($line = array_shift($filecontents))) { $data = $this->get_fields_from_csv($line); $arraykey = reset($data); $object = new \stdClass(); foreach ($keys as $key) { $value = array_shift($data); if ($value !== null) { $object->{$key} = $value; } else { $object->{$key} = ''; } } $records[$arraykey] = $object; } return $records; } } PK9B\}%! ! 7report/statistics/tests/quiz_statistics_report_test.phpnu[. namespace quiz_statistics; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->dirroot . '/mod/quiz/report/statistics/report.php'); require_once($CFG->dirroot . '/mod/quiz/report/statistics/statisticslib.php'); require_once($CFG->dirroot . '/mod/quiz/report/reportlib.php'); require_once($CFG->dirroot . '/mod/quiz/tests/quiz_question_helper_test_trait.php'); /** * Tests for statistics report * * @package quiz_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 * @covers \quiz_statistics_report */ final class quiz_statistics_report_test extends \advanced_testcase { use \quiz_question_helper_test_trait; /** * Secondary database connection for creating locks. * * @var \moodle_database|null */ protected static ?\moodle_database $lockdb; /** * Lock factory using the secondary database connection. * * @var \moodle_database|null */ protected static ?\core\lock\lock_factory $lockfactory; /** * Create a lock factory with a second database session. * * This allows us to create a lock in our test code that will block a lock request * on the same key in code under test. */ public function setUp(): void { global $CFG; parent::setUp(); self::$lockdb = \moodle_database::get_driver_instance($CFG->dbtype, $CFG->dblibrary); self::$lockdb->connect($CFG->dbhost, $CFG->dbuser, $CFG->dbpass, $CFG->dbname, $CFG->prefix, $CFG->dboptions); $lockfactoryclass = \core\lock\lock_config::get_lock_factory_class(); $lockfactory = new $lockfactoryclass('quiz_statistics_get_stats'); // Iterate lock factory hierarchy to see if it contains a 'db' property we can use. $reflectionclass = new \ReflectionClass($lockfactory); while ($reflectionclass) { if ($reflectionhasdb = $reflectionclass->hasProperty('db')) { break; } $reflectionclass = $reflectionclass->getParentClass(); } if (!$reflectionhasdb) { $this->markTestSkipped('Test lock factory should be a db type'); } $reflectiondb = new \ReflectionProperty($lockfactory, 'db'); $reflectiondb->setValue($lockfactory, self::$lockdb); self::$lockfactory = $lockfactory; } /** * Dispose of the extra DB connection and lock factory. */ public function tearDown(): void { self::$lockdb->dispose(); self::$lockdb = null; self::$lockfactory = null; parent::tearDown(); } /** * Return a generated quiz * * @return \stdClass */ protected function create_and_attempt_quiz(): \stdClass { $course = $this->getDataGenerator()->create_course(); $user = $this->getDataGenerator()->create_user(); $quiz = $this->create_test_quiz($course); $quizcontext = \context_module::instance($quiz->cmid); $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); $this->add_two_regular_questions($questiongenerator, $quiz, ['contextid' => $quizcontext->id]); $this->attempt_quiz($quiz, $user); return $quiz; } /** * Test locking the calculation process. * * When there is a lock on the hash code, test_get_all_stats_and_analysis() should wait until the lock timeout, then throw an * exception. * * When there is no lock (or the lock has been released), it should return a result. * * @return void */ public function test_get_all_stats_and_analysis_locking(): void { $this->resetAfterTest(true); $quiz = $this->create_and_attempt_quiz(); $whichattempts = QUIZ_GRADEAVERAGE; // All attempts. $whichtries = \question_attempt::ALL_TRIES; $groupstudentsjoins = new \core\dml\sql_join(); $qubaids = quiz_statistics_qubaids_condition($quiz->id, $groupstudentsjoins, $whichattempts); $report = new \quiz_statistics_report(); $questions = $report->load_and_initialise_questions_for_calculations($quiz); $timeoutseconds = 20; set_config('getstatslocktimeout', $timeoutseconds, 'quiz_statistics'); $lock = self::$lockfactory->get_lock($qubaids->get_hash_code(), 0); $progress = new \core\progress\none(); $this->resetDebugging(); $timebefore = microtime(true); try { $result = $report->get_all_stats_and_analysis( $quiz, $whichattempts, $whichtries, $groupstudentsjoins, $questions, $progress ); $timeafter = microtime(true); // Verify that we waited as long as the timeout. $this->assertEqualsWithDelta($timeoutseconds, $timeafter - $timebefore, 1); $this->assertDebuggingCalled('Could not get lock on ' . $qubaids->get_hash_code() . ' (Quiz ID ' . $quiz->id . ') after ' . $timeoutseconds . ' seconds'); $this->assertEquals([null, null], $result); } finally { $lock->release(); } $this->resetDebugging(); $result = $report->get_all_stats_and_analysis( $quiz, $whichattempts, $whichtries, $groupstudentsjoins, $questions ); $this->assertDebuggingNotCalled(); $this->assertNotEquals([null, null], $result); } /** * Test locking when the current page does not require calculations. * * When there is a lock on the hash code, test_get_all_stats_and_analysis() should return a null result immediately, * with no exception thrown. * * @return void */ public function test_get_all_stats_and_analysis_locking_no_calculation(): void { $this->resetAfterTest(true); $quiz = $this->create_and_attempt_quiz(); $whichattempts = QUIZ_GRADEAVERAGE; // All attempts. $whichtries = \question_attempt::ALL_TRIES; $groupstudentsjoins = new \core\dml\sql_join(); $qubaids = quiz_statistics_qubaids_condition($quiz->id, $groupstudentsjoins, $whichattempts); $report = new \quiz_statistics_report(); $questions = $report->load_and_initialise_questions_for_calculations($quiz); $timeoutseconds = 20; set_config('getstatslocktimeout', $timeoutseconds, 'quiz_statistics'); $lock = self::$lockfactory->get_lock($qubaids->get_hash_code(), 0); $this->resetDebugging(); try { $progress = new \core\progress\none(); $timebefore = microtime(true); $result = $report->get_all_stats_and_analysis( $quiz, $whichattempts, $whichtries, $groupstudentsjoins, $questions, $progress, false ); $timeafter = microtime(true); // Verify that we did not wait for the timeout before returning. $this->assertLessThan($timeoutseconds, $timeafter - $timebefore); $this->assertEquals([null, null], $result); $this->assertDebuggingNotCalled(); } finally { $lock->release(); } } } PK9B\O8report/statistics/tests/quiz_structure_modified_test.phpnu[. namespace quiz_statistics; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->dirroot . '/mod/quiz/tests/quiz_question_helper_test_trait.php'); use core\progress\none; use mod_quiz\grade_calculator; use mod_quiz\quiz_settings; /** * Unit tests for quiz_statistics\event\observer\slots_updated * * @package quiz_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 * @covers \quiz_statistics\quiz_structure_modified */ final class quiz_structure_modified_test extends \advanced_testcase { use \quiz_question_helper_test_trait; /** * Clear the statistics cache for a quiz when it structure is modified. * * When recompute_quiz_sumgrades() is called, it should trigger this plugin's quiz_structure_modified callback * which clears the statistics cache for the quiz. * * @return void */ public function test_clear_cache_on_structure_modified(): void { global $DB; $this->resetAfterTest(true); // Create and attempt a quiz. $generator = $this->getDataGenerator(); $user = $generator->create_user(); $course = $generator->create_course(); $quiz = $this->create_test_quiz($course); $questiongenerator = $generator->get_plugin_generator('core_question'); $category = $questiongenerator->create_question_category(); $question = $questiongenerator->create_question('match', null, ['category' => $category->id]); $questiongenerator->update_question($question); quiz_add_quiz_question($question->id, $quiz); [, , $attempt] = $this->attempt_quiz($quiz, $user); // Run the statistics calculation to prime the cache. $report = new \quiz_statistics_report(); $questions = $report->load_and_initialise_questions_for_calculations($quiz); $report->get_all_stats_and_analysis( $quiz, $quiz->grademethod, \question_attempt::ALL_TRIES, new \core\dml\sql_join(), $questions, new none(), ); $hashcode = quiz_statistics_qubaids_condition($quiz->id, new \core\dml\sql_join(), $quiz->grademethod)->get_hash_code(); $this->assertTrue($DB->record_exists('quiz_statistics', ['hashcode' => $hashcode])); $this->assertTrue($DB->record_exists('question_statistics', ['hashcode' => $hashcode])); $this->assertTrue($DB->record_exists('question_response_analysis', ['hashcode' => $hashcode])); // Recompute sumgrades, which triggers the quiz_structure_modified callback. grade_calculator::create($attempt->get_quizobj())->recompute_quiz_sumgrades(); $this->assertFalse($DB->record_exists('quiz_statistics', ['hashcode' => $hashcode])); $this->assertFalse($DB->record_exists('question_statistics', ['hashcode' => $hashcode])); $this->assertFalse($DB->record_exists('question_response_analysis', ['hashcode' => $hashcode])); } } PK9B\mlQ Q 7report/statistics/tests/behat/missing_questions.featurenu[@mod @mod_quiz @quiz @quiz_statistics Feature: Robustness of the statistics calculations with missing qusetions In order to be able to install and uninstall plugins As a teacher I need the statistics to work even if a question type has been uninstalled Scenario: Statistics can be calculated even after a question type has been uninstalled Given the following "users" exist: | username | | teacher | | student | And the following "courses" exist: | fullname | shortname | | Course 1 | C1 | 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 | | Test questions | truefalse | Test question 1 | | Test questions | truefalse | Test question 2 | And the following "activities" exist: | activity | name | course | idnumber | | quiz | Quiz 1 | C1 | quiz1 | And quiz "Quiz 1" contains the following questions: | question | page | | Test question 1 | 1 | | Test question 2 | 1 | And user "student" has attempted "Quiz 1" with responses: | slot | response | | 1 | True | | 2 | True | And question "Test question 1" is changed to simulate being of an uninstalled type And question "Test question 2" no longer exists in the database When I am on the "Quiz 1" "mod_quiz > Statistics report" page logged in as teacher Then I should see "Quiz structure analysis" And "1" row "Question name" column of "questionstatistics" table should contain "Missing question" And "1" row "Attempts" column of "questionstatistics" table should contain "1" And "1" row "Intended weight" column of "questionstatistics" table should contain "50.00%" And "2" row "Question name" column of "questionstatistics" table should contain "Missing question" And "2" row "Attempts" column of "questionstatistics" table should contain "1" And "2" row "Intended weight" column of "questionstatistics" table should contain "50.00%" PK9B\}B-r r 6report/statistics/tests/behat/random_questions.featurenu[@mod @mod_quiz @quiz @quiz_statistics Feature: Statistics calculations with random questions In order to verify my quizzes are performing well As a teacher I need the statistics to analyse any random questions they contain Background: Given the following "users" exist: | username | | teacher | | student | And the following "courses" exist: | fullname | shortname | | Course 1 | C1 | 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 | Scenario: Statistics can be calculated for random essays Given the following "questions" exist: | questioncategory | qtype | template | name | questiontext | | Test questions | essay | plain | Test question 1 | | | Test questions | essay | plain | Test question 2 | | | Test questions | random | | Random (Test questions) | 0 | And the following "activities" exist: | activity | name | course | idnumber | | quiz | Quiz 1 | C1 | quiz1 | And quiz "Quiz 1" contains the following questions: | question | page | | Random (Test questions) | 1 | And user "student" has attempted "Quiz 1" with responses: | slot | response | | 1 | Here is my wonderful essay | When I am on the "Quiz 1" "mod_quiz > Statistics report" page logged in as teacher Then I should see "No attempts have been made at this quiz, or all attempts have questions that need manual grading." Scenario: View details works for random questions Given the following "questions" exist: | questioncategory | qtype | template | name | questiontext | | Test questions | multichoice | one_of_four | Test question 1 | | | Test questions | multichoice | one_of_four | Test question 2 | | | Test questions | random | | Random (Test questions) | 0 | And the following "activities" exist: | activity | name | course | idnumber | | quiz | Quiz 1 | C1 | quiz1 | And quiz "Quiz 1" contains the following questions: | question | page | | Random (Test questions) | 1 | And user "student" has attempted "Quiz 1" with responses: | slot | response | | 1 | One | When I am on the "Quiz 1" "mod_quiz > Statistics report" page logged in as teacher And I follow "View details" Then I should see "Structural analysis for question number 1" PK9B\ +report/statistics/tests/behat/basic.featurenu[@mod @mod_quiz @quiz @quiz_statistics Feature: Basic use of the Statistics report In order to see how my students are progressing As a teacher I need to see all their quiz responses Background: Given the following "users" exist: | username | firstname | lastname | email | | teacher1 | Teacher | 1 | teacher1@example.com | | student1 | Student | 1 | student1@example.com | | student2 | Student | 2 | student2@example.com | | student3 | Student | 3 | student3@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 | | student2 | C1 | student | | student3 | C1 | student | And the following "question categories" exist: | contextlevel | reference | name | | Course | C1 | Test questions | And the following "questions" exist: | questioncategory | qtype | name | questiontext | | Test questions | truefalse | Question A | This is question 01 | | Test questions | truefalse | Question B | This is question 02 | | Test questions | truefalse | Question C | This is question 03 | And the following "activities" exist: | activity | name | course | idnumber | | quiz | Quiz 1 | C1 | quiz1 | And quiz "Quiz 1" contains the following questions: | question | page | displaynumber | | Question A | 1 | | | Question B | 1 | | | Question C | 2 | 3c | @javascript Scenario: Report works when there are no attempts When I am on the "Quiz 1" "mod_quiz > Statistics report" page logged in as teacher1 Then I should see "No attempts have been made at this quiz, or all attempts have questions that need manual grading." And I should not see "Statistics for question positions" And "Show chart data" "link" should not exist And user "student1" has attempted "Quiz 1" with responses: | slot | response | | 1 | True | | 2 | False | | 3 | False | And user "student2" has attempted "Quiz 1" with responses: | slot | response | | 1 | True | | 2 | True | | 3 | True | And user "student3" has attempted "Quiz 1" with responses: | slot | response | | 1 | False | | 2 | False | | 3 | False | And I am on the "Quiz 1" "mod_quiz > Statistics report" page logged in as teacher1 And I press "Show report" And I should not see "No questions have been attempted yet" And "Show chart data" "link" should exist # Question A statistics breakdown. And "1" row "Q#" column of "questionstatistics" table should contain "1" And "1" row "Question name" column of "questionstatistics" table should contain "Question A" And "1" row "Attempts" column of "questionstatistics" table should contain "3" And "1" row "Facility index" column of "questionstatistics" table should contain "66.67%" And "1" row "Standard deviation" column of "questionstatistics" table should contain "57.74%" And "1" row "Random guess score" column of "questionstatistics" table should contain "50.00%" And "1" row "Intended weight" column of "questionstatistics" table should contain "33.33%" And "1" row "Effective weight" column of "questionstatistics" table should contain "30.90%" And "1" row "Discrimination index" column of "questionstatistics" table should contain "50.00%" # Question B statistics breakdown. And "2" row "Q#" column of "questionstatistics" table should contain "2" And "2" row "Question name" column of "questionstatistics" table should contain "Question B" And "2" row "Attempts" column of "questionstatistics" table should contain "3" And "2" row "Facility index" column of "questionstatistics" table should contain "33.33%" And "2" row "Standard deviation" column of "questionstatistics" table should contain "57.74%" And "2" row "Random guess score" column of "questionstatistics" table should contain "50.00%" And "2" row "Intended weight" column of "questionstatistics" table should contain "33.33%" And "2" row "Effective weight" column of "questionstatistics" table should contain "34.55%" And "2" row "Discrimination index" column of "questionstatistics" table should contain "86.60%" # Question C statistics breakdown. And "3" row "Q#" column of "questionstatistics" table should contain "3c" And "3" row "Question name" column of "questionstatistics" table should contain "Question C" And "3" row "Attempts" column of "questionstatistics" table should contain "3" And "3" row "Facility index" column of "questionstatistics" table should contain "33.33%" And "3" row "Standard deviation" column of "questionstatistics" table should contain "57.74%" And "3" row "Random guess score" column of "questionstatistics" table should contain "50.00%" And "3" row "Intended weight" column of "questionstatistics" table should contain "33.33%" And "3" row "Effective weight" column of "questionstatistics" table should contain "34.55%" And "3" row "Discrimination index" column of "questionstatistics" table should contain "86.60%" PK9B\H!report/statistics/settings.phpnu[. /** * Settings for the Statisics report * * @package quiz_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 */ defined('MOODLE_INTERNAL') || die; if ($ADMIN->fulltree) { $settings->add(new admin_setting_configtext( 'quiz_statistics/getstatslocktimeout', get_string('getstatslocktimeout', 'quiz_statistics'), get_string('getstatslocktimeoutdesc', 'quiz_statistics'), MINSECS * 15 )); } PK9B\5_== settings.phpnu[. /** * Administration settings definitions for the quiz module. * * @package mod_quiz * @copyright 2010 Petr Skoda * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ use mod_quiz\admin\review_setting; defined('MOODLE_INTERNAL') || die(); require_once($CFG->dirroot . '/mod/quiz/lib.php'); // First get a list of quiz reports with there own settings pages. If there none, // we use a simpler overall menu structure. $reports = core_component::get_plugin_list_with_file('quiz', 'settings.php', false); $reportsbyname = []; foreach ($reports as $report => $reportdir) { $strreportname = get_string($report . 'report', 'quiz_'.$report); $reportsbyname[$strreportname] = $report; } core_collator::ksort($reportsbyname); // First get a list of quiz reports with there own settings pages. If there none, // we use a simpler overall menu structure. $rules = core_component::get_plugin_list_with_file('quizaccess', 'settings.php', false); $rulesbyname = []; foreach ($rules as $rule => $ruledir) { $strrulename = get_string('pluginname', 'quizaccess_' . $rule); $rulesbyname[$strrulename] = $rule; } core_collator::ksort($rulesbyname); // Create the quiz settings page. if (empty($reportsbyname) && empty($rulesbyname)) { $pagetitle = get_string('modulename', 'quiz'); } else { $pagetitle = get_string('generalsettings', 'admin'); } $quizsettings = new admin_settingpage('modsettingquiz', $pagetitle, 'moodle/site:config'); if ($ADMIN->fulltree) { $quizsettings->add(new admin_setting_heading('quizintro', '', get_string('configintroglobal', 'quiz'))); // Delay to notify graded attempts. $quizsettings->add(new admin_setting_configduration('quiz/notifyattemptgradeddelay', get_string('attemptgradeddelay', 'quiz'), get_string('attemptgradeddelay_desc', 'quiz'), 5 * HOURSECS, HOURSECS)); // Pre-create attempt period. $precreateoptions = [get_string('precreateoff', 'quiz')]; for ($i = 1; $i <= 24; $i++) { $precreateoptions[$i * HOURSECS] = sprintf(get_string('dateintervalhrfull', 'langconfig'), $i); } $setting = new admin_setting_configselect( 'quiz/precreateperiod', get_string('precreateperiod', 'quiz'), get_string('precreateperiod_desc', 'quiz'), 0, $precreateoptions, ); $quizsettings->add($setting); // Minimum grace period used behind the scenes. $quizsettings->add(new admin_setting_configduration('quiz/graceperiodmin', get_string('graceperiodmin', 'quiz'), get_string('graceperiodmin_desc', 'quiz'), 60, 1)); // Initial number of feedback items. $quizsettings->add(new admin_setting_configtext('quiz/initialnumfeedbacks', get_string('initialnumfeedbacks', 'quiz'), get_string('initialnumfeedbacks_desc', 'quiz'), 2, PARAM_INT, 5)); // Autosave frequency. $quizsettings->add(new admin_setting_configduration('quiz/autosaveperiod', get_string('autosaveperiod', 'quiz'), get_string('autosaveperiod_desc', 'quiz'), 60, 1)); // Heading explanation that all the settings below are defaults for the add quiz form. $name = new lang_string('defaultsettings', 'quiz'); $description = new lang_string('configintro', 'quiz'); $quizsettings->add(new admin_setting_heading('defaultsettings', $name, $description)); // Time limit. $setting = new admin_setting_configduration('quiz/timelimit', get_string('timelimit', 'quiz'), get_string('configtimelimitsec', 'quiz'), '0', 60); $setting->set_advanced_flag_options(admin_setting_flag::ENABLED, false); $setting->set_locked_flag_options(admin_setting_flag::ENABLED, false); $quizsettings->add($setting); // What to do with overdue attempts. $setting = new \mod_quiz\admin\overdue_handling_setting('quiz/overduehandling', get_string('overduehandling', 'quiz'), get_string('overduehandling_desc', 'quiz'), ['value' => 'autosubmit', 'adv' => false], null); $setting->set_locked_flag_options(admin_setting_flag::ENABLED, false); $quizsettings->add($setting); // Grace period time. $setting = new admin_setting_configduration('quiz/graceperiod', get_string('graceperiod', 'quiz'), get_string('graceperiod_desc', 'quiz'), '86400'); $setting->set_advanced_flag_options(admin_setting_flag::ENABLED, false); $setting->set_locked_flag_options(admin_setting_flag::ENABLED, false); $quizsettings->add($setting); // Enable pre-creation of attempts. $setting = new admin_setting_configcheckbox( 'quiz/precreateattempts', get_string('precreateattempts', 'quiz'), get_string('precreateattempts_help', 'quiz'), 0, ); $setting->set_advanced_flag_options(admin_setting_flag::ENABLED, true); $setting->set_locked_flag_options(admin_setting_flag::ENABLED, true); $quizsettings->add($setting); // Number of attempts. $options = [get_string('unlimited')]; for ($i = 1; $i <= QUIZ_MAX_ATTEMPT_OPTION; $i++) { $options[$i] = $i; } $setting = new admin_setting_configselect('quiz/attempts', get_string('attemptsallowed', 'quiz'), get_string('configattemptsallowed', 'quiz'), 0, $options); $setting->set_advanced_flag_options(admin_setting_flag::ENABLED, false); $setting->set_locked_flag_options(admin_setting_flag::ENABLED, false); $quizsettings->add($setting); // Grading method. $setting = new \mod_quiz\admin\grade_method_setting('quiz/grademethod', get_string('grademethod', 'quiz'), get_string('configgrademethod', 'quiz'), ['value' => QUIZ_GRADEHIGHEST, 'adv' => false], null); $setting->set_locked_flag_options(admin_setting_flag::ENABLED, false); $quizsettings->add($setting); // Maximum grade. $setting = new admin_setting_configtext('quiz/maximumgrade', get_string('maximumgrade'), get_string('configmaximumgrade', 'quiz'), 10, PARAM_INT); $setting->set_locked_flag_options(admin_setting_flag::ENABLED, false); $quizsettings->add($setting); // Questions per page. $perpage = []; $perpage[0] = get_string('never'); $perpage[1] = get_string('aftereachquestion', 'quiz'); for ($i = 2; $i <= QUIZ_MAX_QPP_OPTION; ++$i) { $perpage[$i] = get_string('afternquestions', 'quiz', $i); } $setting = new admin_setting_configselect('quiz/questionsperpage', get_string('newpageevery', 'quiz'), get_string('confignewpageevery', 'quiz'), 1, $perpage); $setting->set_advanced_flag_options(admin_setting_flag::ENABLED, false); $setting->set_locked_flag_options(admin_setting_flag::ENABLED, false); $quizsettings->add($setting); // Navigation method. $setting = new admin_setting_configselect('quiz/navmethod', get_string('navmethod', 'quiz'), get_string('confignavmethod', 'quiz'), QUIZ_NAVMETHOD_FREE, quiz_get_navigation_options()); $setting->set_advanced_flag_options(admin_setting_flag::ENABLED, true); $setting->set_locked_flag_options(admin_setting_flag::ENABLED, false); $quizsettings->add($setting); // Shuffle within questions. $setting = new admin_setting_configcheckbox('quiz/shuffleanswers', get_string('shufflewithin', 'quiz'), get_string('configshufflewithin', 'quiz'), 1); $setting->set_advanced_flag_options(admin_setting_flag::ENABLED, false); $setting->set_locked_flag_options(admin_setting_flag::ENABLED, false); $quizsettings->add($setting); // Preferred behaviour. $setting = new admin_setting_question_behaviour('quiz/preferredbehaviour', get_string('howquestionsbehave', 'question'), get_string('howquestionsbehave_desc', 'quiz'), 'deferredfeedback'); $setting->set_locked_flag_options(admin_setting_flag::ENABLED, false); $quizsettings->add($setting); // Can redo completed questions. $setting = new admin_setting_configselect('quiz/canredoquestions', get_string('canredoquestions', 'quiz'), get_string('canredoquestions_desc', 'quiz'), 0, [0 => get_string('no'), 1 => get_string('canredoquestionsyes', 'quiz')]); $setting->set_advanced_flag_options(admin_setting_flag::ENABLED, true); $setting->set_locked_flag_options(admin_setting_flag::ENABLED, false); $quizsettings->add($setting); // Each attempt builds on last. $setting = new admin_setting_configcheckbox('quiz/attemptonlast', get_string('eachattemptbuildsonthelast', 'quiz'), get_string('configeachattemptbuildsonthelast', 'quiz'), 0); $setting->set_advanced_flag_options(admin_setting_flag::ENABLED, true); $setting->set_locked_flag_options(admin_setting_flag::ENABLED, false); $quizsettings->add($setting); // Review options. $quizsettings->add(new admin_setting_heading('reviewheading', get_string('reviewoptionsheading', 'quiz'), '')); foreach (review_setting::fields() as $field => $name) { $default = review_setting::all_on(); $forceduring = null; if ($field == 'attempt') { $forceduring = true; } else if ($field == 'overallfeedback') { $default = $default ^ review_setting::DURING; $forceduring = false; } $quizsettings->add(new review_setting('quiz/review' . $field, $name, '', $default, $forceduring)); } // Show the user's picture. $setting = new \mod_quiz\admin\user_image_setting('quiz/showuserpicture', get_string('showuserpicture', 'quiz'), get_string('configshowuserpicture', 'quiz'), ['value' => 0, 'adv' => false], null); $setting->set_locked_flag_options(admin_setting_flag::ENABLED, false); $quizsettings->add($setting); // Decimal places for overall grades. $options = []; for ($i = 0; $i <= QUIZ_MAX_DECIMAL_OPTION; $i++) { $options[$i] = $i; } $setting = new admin_setting_configselect('quiz/decimalpoints', get_string('decimalplaces', 'quiz'), get_string('configdecimalplaces', 'quiz'), 2, $options); $setting->set_advanced_flag_options(admin_setting_flag::ENABLED, false); $setting->set_locked_flag_options(admin_setting_flag::ENABLED, false); $quizsettings->add($setting); // Decimal places for question grades. $options = [-1 => get_string('sameasoverall', 'quiz')]; for ($i = 0; $i <= QUIZ_MAX_Q_DECIMAL_OPTION; $i++) { $options[$i] = $i; } $setting = new admin_setting_configselect('quiz/questiondecimalpoints', get_string('decimalplacesquestion', 'quiz'), get_string('configdecimalplacesquestion', 'quiz'), -1, $options); $setting->set_advanced_flag_options(admin_setting_flag::ENABLED, false); $setting->set_locked_flag_options(admin_setting_flag::ENABLED, false); $quizsettings->add($setting); // Show blocks during quiz attempts. $setting = new admin_setting_configcheckbox('quiz/showblocks', get_string('showblocks', 'quiz'), get_string('configshowblocks', 'quiz'), 0); $setting->set_advanced_flag_options(admin_setting_flag::ENABLED, true); $setting->set_locked_flag_options(admin_setting_flag::ENABLED, false); $quizsettings->add($setting); // Password. $setting = new admin_setting_configpasswordunmask('quiz/quizpassword', get_string('requirepassword', 'quiz'), get_string('configrequirepassword', 'quiz'), ''); $setting->set_advanced_flag_options(admin_setting_flag::ENABLED, false); $setting->set_required_flag_options(admin_setting_flag::ENABLED, false); $setting->set_locked_flag_options(admin_setting_flag::ENABLED, false); $quizsettings->add($setting); // IP restrictions. $setting = new admin_setting_configtext('quiz/subnet', get_string('requiresubnet', 'quiz'), get_string('configrequiresubnet', 'quiz'), '', PARAM_TEXT); $setting->set_advanced_flag_options(admin_setting_flag::ENABLED, true); $setting->set_locked_flag_options(admin_setting_flag::ENABLED, false); $quizsettings->add($setting); // Enforced delay between attempts. $setting = new admin_setting_configduration('quiz/delay1', get_string('delay1st2nd', 'quiz'), get_string('configdelay1st2nd', 'quiz'), 0, 60); $setting->set_advanced_flag_options(admin_setting_flag::ENABLED, true); $setting->set_locked_flag_options(admin_setting_flag::ENABLED, false); $quizsettings->add($setting); $setting = new admin_setting_configduration('quiz/delay2', get_string('delaylater', 'quiz'), get_string('configdelaylater', 'quiz'), 0, 60); $setting->set_advanced_flag_options(admin_setting_flag::ENABLED, true); $setting->set_locked_flag_options(admin_setting_flag::ENABLED, false); $quizsettings->add($setting); // Browser security. $setting = new \mod_quiz\admin\browser_security_setting('quiz/browsersecurity', get_string('showinsecurepopup', 'quiz'), get_string('configpopup', 'quiz'), ['value' => '-', 'adv' => true], null); $setting->set_locked_flag_options(admin_setting_flag::ENABLED, false); $quizsettings->add($setting); // Allow user to specify if setting outcomes is an advanced setting. if (!empty($CFG->enableoutcomes)) { $quizsettings->add(new admin_setting_configcheckbox('quiz/outcomes_adv', get_string('outcomesadvanced', 'quiz'), get_string('configoutcomesadvanced', 'quiz'), '0')); } } // Now, depending on whether any reports have their own settings page, add // the quiz setting page to the appropriate place in the tree. if (empty($reportsbyname) && empty($rulesbyname)) { $ADMIN->add('modsettings', $quizsettings); } else { $ADMIN->add('modsettings', new admin_category('modsettingsquizcat', get_string('modulename', 'quiz'), $module->is_enabled() === false)); $ADMIN->add('modsettingsquizcat', $quizsettings); // Add settings pages for the quiz report subplugins. foreach ($reportsbyname as $strreportname => $report) { $reportname = $report; $settings = new admin_settingpage('modsettingsquizcat'.$reportname, $strreportname, 'moodle/site:config', $module->is_enabled() === false); include($CFG->dirroot . "/mod/quiz/report/$reportname/settings.php"); if (!empty($settings)) { $ADMIN->add('modsettingsquizcat', $settings); } } // Add settings pages for the quiz access rule subplugins. foreach ($rulesbyname as $strrulename => $rule) { $settings = new admin_settingpage('modsettingsquizcat' . $rule, $strrulename, 'moodle/site:config', $module->is_enabled() === false); include($CFG->dirroot . "/mod/quiz/accessrule/$rule/settings.php"); if (!empty($settings)) { $ADMIN->add('modsettingsquizcat', $settings); } } } $settings = null; // We do not want standard settings link. PK9B\Yδu u editgrading.phpnu[. /** * Page to edit complex grading setups for quizzes * * For quizzes with basic grading, everything can be done on edit.php. * However, more advanced options are possible, if you come to this * separate page. * * @package mod_quiz * @copyright 2023 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ use mod_quiz\output\edit_grading_page; use mod_quiz\output\edit_nav_actions; use mod_quiz\quiz_settings; // The require_login check is done in question_edit_setup, but the automated checker can't see this. // phpcs:ignore moodle.Files.RequireLogin.Missing require_once(__DIR__ . '/../../config.php'); require_once($CFG->dirroot . '/mod/quiz/locallib.php'); require_once($CFG->dirroot . '/question/editlib.php'); list($thispageurl, $contexts, $cmid, $cm, $quiz, $pagevars) = question_edit_setup('editq', '/mod/quiz/editgrading.php'); $PAGE->set_url($thispageurl); $PAGE->set_secondary_active_tab('mod_quiz_edit'); // You need mod/quiz:manage in addition to question capabilities to access this page. require_capability('mod/quiz:manage', $contexts->lowest()); // Get the course object and related bits. $course = get_course($quiz->course); $quizobj = new quiz_settings($quiz, $cm, $course); $structure = $quizobj->get_structure(); $editpage = new edit_grading_page($structure); $quizhasattempts = quiz_has_attempts($quiz->id); // Initialise output. $PAGE->set_pagelayout('incourse'); $output = $PAGE->get_renderer('mod_quiz', 'edit'); $PAGE->set_title(get_string('editingquizx', 'quiz', format_string($quiz->name))); $PAGE->set_heading($course->fullname); $PAGE->activityheader->disable(); $PAGE->set_secondary_active_tab('mod_quiz_edit'); $tertiarynav = new edit_nav_actions($cmid, edit_nav_actions::GRADING); $PAGE->requires->js_call_amd('mod_quiz/edit_multiple_grades', 'init'); // Do output. echo $output->header(); echo $output->render($tertiarynav); echo $output->render($editpage); echo $output->footer(); PK9B\1ZJJ mod_form.phpnu[. /** * Defines the quiz module ettings form. * * @package mod_quiz * @copyright 2006 Jamie Pratt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); require_once($CFG->dirroot . '/course/moodleform_mod.php'); require_once($CFG->dirroot . '/mod/quiz/locallib.php'); use mod_quiz\access_manager; use mod_quiz\question\display_options; /** * Settings form for the quiz module. * * @copyright 2006 Jamie Pratt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class mod_quiz_mod_form extends moodleform_mod { /** @var array options to be used with date_time_selector fields in the quiz. */ public static $datefieldoptions = ['optional' => true]; /** @var array caches the quiz overall feedback, for convenience. */ protected $_feedbacks; /** @var array for convenience stores the list of types of review option. Initialised in the constructor. */ protected static $reviewfields = []; /** @var int the max number of attempts allowed in any user or group override on this quiz. */ protected $maxattemptsanyoverride = null; public function __construct($current, $section, $cm, $course) { self::$reviewfields = [ 'attempt' => ['theattempt', 'quiz'], 'correctness' => ['whethercorrect', 'question'], 'maxmarks' => ['maxmarks', 'quiz'], 'marks' => ['marks', 'quiz'], 'specificfeedback' => ['specificfeedback', 'question'], 'generalfeedback' => ['generalfeedback', 'question'], 'rightanswer' => ['rightanswer', 'question'], 'overallfeedback' => ['reviewoverallfeedback', 'quiz'], ]; parent::__construct($current, $section, $cm, $course); } protected function definition() { global $CFG, $DB, $PAGE; $quizconfig = get_config('quiz'); $mform = $this->_form; // ------------------------------------------------------------------------------- $mform->addElement('header', 'general', get_string('general', 'form')); // Name. $mform->addElement('text', 'name', get_string('name'), ['size' => '64']); if (!empty($CFG->formatstringstriptags)) { $mform->setType('name', PARAM_TEXT); } else { $mform->setType('name', PARAM_CLEANHTML); } $mform->addRule('name', null, 'required', null, 'client'); $mform->addRule('name', get_string('maximumchars', '', 255), 'maxlength', 255, 'client'); // Introduction. $this->standard_intro_elements(get_string('introduction', 'quiz')); // ------------------------------------------------------------------------------- $mform->addElement('header', 'timing', get_string('timing', 'quiz')); // Open and close dates. $mform->addElement('date_time_selector', 'timeopen', get_string('quizopen', 'quiz'), self::$datefieldoptions); $mform->addHelpButton('timeopen', 'quizopenclose', 'quiz'); $mform->addElement('date_time_selector', 'timeclose', get_string('quizclose', 'quiz'), self::$datefieldoptions); // Time limit. $mform->addElement('duration', 'timelimit', get_string('timelimit', 'quiz'), ['optional' => true]); $mform->addHelpButton('timelimit', 'timelimit', 'quiz'); // What to do with overdue attempts. $mform->addElement('select', 'overduehandling', get_string('overduehandling', 'quiz'), quiz_get_overdue_handling_options()); $mform->addHelpButton('overduehandling', 'overduehandling', 'quiz'); // TODO Formslib does OR logic on disableif, and we need AND logic here. // $mform->disabledIf('overduehandling', 'timelimit', 'eq', 0); // $mform->disabledIf('overduehandling', 'timeclose', 'eq', 0); // Grace period time. $mform->addElement('duration', 'graceperiod', get_string('graceperiod', 'quiz'), ['optional' => true]); $mform->addHelpButton('graceperiod', 'graceperiod', 'quiz'); $mform->hideIf('graceperiod', 'overduehandling', 'neq', 'graceperiod'); // Pre-create attempts. // This is only shown if "Pre-create period" as been set at site level, and the quiz open time is enabled. $precreateperiod = get_config('quiz', 'precreateperiod'); if (!empty($precreateperiod)) { $yesoption = get_string('precreateyes', 'quiz', $precreateperiod / HOURSECS); $precreateoptions = [ 1 => $yesoption, 0 => get_string('no'), ]; $mform->addElement( 'select', 'precreateattempts', get_string('precreateattempts', 'quiz'), $precreateoptions ); $mform->hideIf('precreateattempts', 'timeopen[enabled]'); $mform->addHelpButton('precreateattempts', 'precreateattempts', 'quiz'); } // ------------------------------------------------------------------------------- // Grade settings. $this->standard_grading_coursemodule_elements(); $mform->removeElement('grade'); if (property_exists($this->current, 'grade')) { $currentgrade = $this->current->grade; } else { $currentgrade = $quizconfig->maximumgrade; } $mform->addElement('hidden', 'grade', $currentgrade); $mform->setType('grade', PARAM_FLOAT); // Number of attempts. $attemptoptions = ['0' => get_string('unlimited')]; for ($i = 1; $i <= QUIZ_MAX_ATTEMPT_OPTION; $i++) { $attemptoptions[$i] = $i; } $mform->addElement('select', 'attempts', get_string('attemptsallowed', 'quiz'), $attemptoptions); // Grading method. $mform->addElement('select', 'grademethod', get_string('grademethod', 'quiz'), quiz_get_grading_options()); $mform->addHelpButton('grademethod', 'grademethod', 'quiz'); if ($this->get_max_attempts_for_any_override() < 2) { $mform->hideIf('grademethod', 'attempts', 'eq', 1); } // ------------------------------------------------------------------------------- $mform->addElement('header', 'layouthdr', get_string('layout', 'quiz')); $pagegroup = []; $pagegroup[] = $mform->createElement('select', 'questionsperpage', get_string('newpage', 'quiz'), quiz_questions_per_page_options(), ['id' => 'id_questionsperpage']); $mform->setDefault('questionsperpage', $quizconfig->questionsperpage); if (!empty($this->_cm) && !quiz_has_attempts($this->_cm->instance)) { $pagegroup[] = $mform->createElement('checkbox', 'repaginatenow', '', get_string('repaginatenow', 'quiz'), ['id' => 'id_repaginatenow']); } $mform->addGroup($pagegroup, 'questionsperpagegrp', get_string('newpage', 'quiz'), null, false); $mform->addHelpButton('questionsperpagegrp', 'newpage', 'quiz'); $mform->setAdvanced('questionsperpagegrp', $quizconfig->questionsperpage_adv); // Navigation method. $mform->addElement('select', 'navmethod', get_string('navmethod', 'quiz'), quiz_get_navigation_options()); $mform->addHelpButton('navmethod', 'navmethod', 'quiz'); // ------------------------------------------------------------------------------- $mform->addElement('header', 'interactionhdr', get_string('questionbehaviour', 'quiz')); // Shuffle within questions. $mform->addElement('selectyesno', 'shuffleanswers', get_string('shufflewithin', 'quiz')); $mform->addHelpButton('shuffleanswers', 'shufflewithin', 'quiz'); // How questions behave (question behaviour). if (!empty($this->current->preferredbehaviour)) { $currentbehaviour = $this->current->preferredbehaviour; } else { $currentbehaviour = ''; } $behaviours = question_engine::get_behaviour_options($currentbehaviour); $mform->addElement('select', 'preferredbehaviour', get_string('howquestionsbehave', 'question'), $behaviours); $mform->addHelpButton('preferredbehaviour', 'howquestionsbehave', 'question'); // Can redo completed questions. $redochoices = [0 => get_string('no'), 1 => get_string('canredoquestionsyes', 'quiz')]; $mform->addElement('select', 'canredoquestions', get_string('canredoquestions', 'quiz'), $redochoices); $mform->addHelpButton('canredoquestions', 'canredoquestions', 'quiz'); foreach ($behaviours as $behaviour => $notused) { if (!question_engine::can_questions_finish_during_the_attempt($behaviour)) { $mform->hideIf('canredoquestions', 'preferredbehaviour', 'eq', $behaviour); } } // Each attempt builds on last. $mform->addElement('selectyesno', 'attemptonlast', get_string('eachattemptbuildsonthelast', 'quiz')); $mform->addHelpButton('attemptonlast', 'eachattemptbuildsonthelast', 'quiz'); if ($this->get_max_attempts_for_any_override() < 2) { $mform->hideIf('attemptonlast', 'attempts', 'eq', 1); } // ------------------------------------------------------------------------------- $mform->addElement('header', 'reviewoptionshdr', get_string('reviewoptionsheading', 'quiz')); $mform->addHelpButton('reviewoptionshdr', 'reviewoptionsheading', 'quiz'); // Review options. $this->add_review_options_group($mform, $quizconfig, 'during', display_options::DURING, true); $this->add_review_options_group($mform, $quizconfig, 'immediately', display_options::IMMEDIATELY_AFTER); $this->add_review_options_group($mform, $quizconfig, 'open', display_options::LATER_WHILE_OPEN); $this->add_review_options_group($mform, $quizconfig, 'closed', display_options::AFTER_CLOSE); foreach ($behaviours as $behaviour => $notused) { $unusedoptions = question_engine::get_behaviour_unused_display_options($behaviour); foreach ($unusedoptions as $unusedoption) { $mform->disabledIf($unusedoption . 'during', 'preferredbehaviour', 'eq', $behaviour); } } $mform->disabledIf('attemptduring', 'preferredbehaviour', 'neq', 'wontmatch'); $mform->disabledIf('overallfeedbackduring', 'preferredbehaviour', 'neq', 'wontmatch'); foreach (self::$reviewfields as $field => $notused) { $mform->disabledIf($field . 'closed', 'timeclose[enabled]'); } // ------------------------------------------------------------------------------- $mform->addElement('header', 'display', get_string('appearance')); // Show user picture. $mform->addElement('select', 'showuserpicture', get_string('showuserpicture', 'quiz'), quiz_get_user_image_options()); $mform->addHelpButton('showuserpicture', 'showuserpicture', 'quiz'); // Overall decimal points. $options = []; for ($i = 0; $i <= QUIZ_MAX_DECIMAL_OPTION; $i++) { $options[$i] = $i; } $mform->addElement('select', 'decimalpoints', get_string('decimalplaces', 'quiz'), $options); $mform->addHelpButton('decimalpoints', 'decimalplaces', 'quiz'); // Question decimal points. $options = [-1 => get_string('sameasoverall', 'quiz')]; for ($i = 0; $i <= QUIZ_MAX_Q_DECIMAL_OPTION; $i++) { $options[$i] = $i; } $mform->addElement('select', 'questiondecimalpoints', get_string('decimalplacesquestion', 'quiz'), $options); $mform->addHelpButton('questiondecimalpoints', 'decimalplacesquestion', 'quiz'); // Show blocks during quiz attempt. $mform->addElement('selectyesno', 'showblocks', get_string('showblocks', 'quiz')); $mform->addHelpButton('showblocks', 'showblocks', 'quiz'); // ------------------------------------------------------------------------------- $mform->addElement('header', 'security', get_string('extraattemptrestrictions', 'quiz')); // Require password to begin quiz attempt. $mform->addElement('passwordunmask', 'quizpassword', get_string('requirepassword', 'quiz')); $mform->setType('quizpassword', PARAM_TEXT); $mform->addHelpButton('quizpassword', 'requirepassword', 'quiz'); // IP address. $mform->addElement('text', 'subnet', get_string('requiresubnet', 'quiz')); $mform->setType('subnet', PARAM_TEXT); $mform->addHelpButton('subnet', 'requiresubnet', 'quiz'); // Enforced time delay between quiz attempts. $mform->addElement('duration', 'delay1', get_string('delay1st2nd', 'quiz'), ['optional' => true]); $mform->addHelpButton('delay1', 'delay1st2nd', 'quiz'); if ($this->get_max_attempts_for_any_override() < 2) { $mform->hideIf('delay1', 'attempts', 'eq', 1); } $mform->addElement('duration', 'delay2', get_string('delaylater', 'quiz'), ['optional' => true]); $mform->addHelpButton('delay2', 'delaylater', 'quiz'); if ($this->get_max_attempts_for_any_override() < 3) { $mform->hideIf('delay2', 'attempts', 'eq', 1); $mform->hideIf('delay2', 'attempts', 'eq', 2); } // Browser security choices. $mform->addElement('select', 'browsersecurity', get_string('browsersecurity', 'quiz'), access_manager::get_browser_security_choices()); $mform->addHelpButton('browsersecurity', 'browsersecurity', 'quiz'); // Any other rule plugins. access_manager::add_settings_form_fields($this, $mform); // ------------------------------------------------------------------------------- $mform->addElement('header', 'overallfeedbackhdr', get_string('overallfeedback', 'quiz')); $mform->addHelpButton('overallfeedbackhdr', 'overallfeedback', 'quiz'); if (isset($this->current->grade)) { $needwarning = $this->current->grade === 0; } else { $needwarning = $quizconfig->maximumgrade == 0; } if ($needwarning) { $mform->addElement('static', 'nogradewarning', '', get_string('nogradewarning', 'quiz')); } $mform->addElement('static', 'gradeboundarystatic1', get_string('gradeboundary', 'quiz'), '100%'); $repeatarray = []; $repeatedoptions = []; $repeatarray[] = $mform->createElement('editor', 'feedbacktext', get_string('feedback', 'quiz'), ['rows' => 3], ['maxfiles' => EDITOR_UNLIMITED_FILES, 'noclean' => true, 'context' => $this->context]); $repeatarray[] = $mform->createElement('text', 'feedbackboundaries', get_string('gradeboundary', 'quiz'), ['size' => 10]); $repeatedoptions['feedbacktext']['type'] = PARAM_RAW; $repeatedoptions['feedbackboundaries']['type'] = PARAM_RAW; if (!empty($this->_instance)) { $this->_feedbacks = $DB->get_records('quiz_feedback', ['quizid' => $this->_instance], 'mingrade DESC'); $numfeedbacks = count($this->_feedbacks); } else { $this->_feedbacks = []; $numfeedbacks = $quizconfig->initialnumfeedbacks; } $numfeedbacks = max($numfeedbacks, 1); $nextel = $this->repeat_elements($repeatarray, $numfeedbacks - 1, $repeatedoptions, 'boundary_repeats', 'boundary_add_fields', 3, get_string('addmoreoverallfeedbacks', 'quiz'), true); // Put some extra elements in before the button. $mform->insertElementBefore($mform->createElement('editor', "feedbacktext[$nextel]", get_string('feedback', 'quiz'), ['rows' => 3], ['maxfiles' => EDITOR_UNLIMITED_FILES, 'noclean' => true, 'context' => $this->context]), 'boundary_add_fields'); $mform->insertElementBefore($mform->createElement('static', 'gradeboundarystatic2', get_string('gradeboundary', 'quiz'), '0%'), 'boundary_add_fields'); // Add the disabledif rules. We cannot do this using the $repeatoptions parameter to // repeat_elements because we don't want to dissable the first feedbacktext. for ($i = 0; $i < $nextel; $i++) { $mform->disabledIf('feedbackboundaries[' . $i . ']', 'grade', 'eq', 0); $mform->disabledIf('feedbacktext[' . ($i + 1) . ']', 'grade', 'eq', 0); } // ------------------------------------------------------------------------------- $this->standard_coursemodule_elements(); // Check and act on whether setting outcomes is considered an advanced setting. $mform->setAdvanced('modoutcomes', !empty($quizconfig->outcomes_adv)); // The standard_coursemodule_elements method sets this to 100, but the // quiz has its own setting, so use that. $mform->setDefault('grade', $quizconfig->maximumgrade); // ------------------------------------------------------------------------------- $this->apply_admin_defaults(); $this->add_action_buttons(); $PAGE->requires->yui_module('moodle-mod_quiz-modform', 'M.mod_quiz.modform.init'); } protected function add_review_options_group($mform, $quizconfig, $whenname, $when, $withhelp = false) { global $OUTPUT; $group = []; foreach (self::$reviewfields as $field => $string) { list($identifier, $component) = $string; $label = get_string($identifier, $component); $group[] = $mform->createElement('html', html_writer::start_div('review_option_item')); $el = $mform->createElement('checkbox', $field . $whenname, '', $label); if ($withhelp) { $el->_helpbutton = $OUTPUT->render(new help_icon($identifier, $component)); } $group[] = $el; $group[] = $mform->createElement('html', html_writer::end_div()); } $mform->addGroup($group, $whenname . 'optionsgrp', get_string('review' . $whenname, 'quiz'), null, false); foreach (self::$reviewfields as $field => $notused) { $cfgfield = 'review' . $field; if ($quizconfig->$cfgfield & $when) { $mform->setDefault($field . $whenname, 1); } else { $mform->setDefault($field . $whenname, 0); } } $mform->disabledIf('marks' . $whenname, 'maxmarks' . $whenname); if ($whenname != 'during') { $mform->disabledIf('correctness' . $whenname, 'attempt' . $whenname); $mform->disabledIf('specificfeedback' . $whenname, 'attempt' . $whenname); $mform->disabledIf('generalfeedback' . $whenname, 'attempt' . $whenname); $mform->disabledIf('rightanswer' . $whenname, 'attempt' . $whenname); } } protected function preprocessing_review_settings(&$toform, $whenname, $when) { foreach (self::$reviewfields as $field => $notused) { $fieldname = 'review' . $field; if (array_key_exists($fieldname, $toform)) { $toform[$field . $whenname] = $toform[$fieldname] & $when; } } } public function data_preprocessing(&$toform) { if (isset($toform['grade'])) { // Convert to a real number, so we don't get 0.0000. $toform['grade'] = $toform['grade'] + 0; } if (count($this->_feedbacks)) { $key = 0; foreach ($this->_feedbacks as $feedback) { $draftid = file_get_submitted_draft_itemid('feedbacktext['.$key.']'); $toform['feedbacktext['.$key.']']['text'] = file_prepare_draft_area( $draftid, // Draftid. $this->context->id, // Context. 'mod_quiz', // Component. 'feedback', // Filarea. !empty($feedback->id) ? (int) $feedback->id : null, // Itemid. null, $feedback->feedbacktext // Text. ); $toform['feedbacktext['.$key.']']['format'] = $feedback->feedbacktextformat; $toform['feedbacktext['.$key.']']['itemid'] = $draftid; if ($toform['grade'] == 0) { // When a quiz is un-graded, there can only be one lot of // feedback. If the quiz previously had a maximum grade and // several lots of feedback, we must now avoid putting text // into input boxes that are disabled, but which the // validation will insist are blank. break; } if ($feedback->mingrade > 0) { $toform['feedbackboundaries['.$key.']'] = round(100.0 * $feedback->mingrade / $toform['grade'], 6) . '%'; } $key++; } } if (isset($toform['timelimit'])) { $toform['timelimitenable'] = $toform['timelimit'] > 0; } $this->preprocessing_review_settings($toform, 'during', display_options::DURING); $this->preprocessing_review_settings($toform, 'immediately', display_options::IMMEDIATELY_AFTER); $this->preprocessing_review_settings($toform, 'open', display_options::LATER_WHILE_OPEN); $this->preprocessing_review_settings($toform, 'closed', display_options::AFTER_CLOSE); $toform['attemptduring'] = true; $toform['overallfeedbackduring'] = false; // Password field - different in form to stop browsers that remember // passwords from getting confused. if (isset($toform['password'])) { $toform['quizpassword'] = $toform['password']; unset($toform['password']); } // Load any settings belonging to the access rules. if (!empty($toform['instance'])) { $accesssettings = access_manager::load_settings($toform['instance']); foreach ($accesssettings as $name => $value) { $toform[$name] = $value; } } $suffix = $this->get_suffix(); $completionminattemptsel = 'completionminattempts' . $suffix; if (empty($toform[$completionminattemptsel])) { $toform[$completionminattemptsel] = 1; } else { $completionminattemptsenabledel = 'completionminattemptsenabled' . $suffix; $toform[$completionminattemptsenabledel] = $toform[$completionminattemptsel] > 0; } } /** * Allows module to modify the data returned by form get_data(). * This method is also called in the bulk activity completion form. * * Only available on moodleform_mod. * * @param stdClass $data the form data to be modified. */ public function data_postprocessing($data) { parent::data_postprocessing($data); if (!empty($data->completionunlocked)) { // Turn off completion settings if the checkboxes aren't ticked. $suffix = $this->get_suffix(); $completion = $data->{'completion' . $suffix}; $autocompletion = !empty($completion) && $completion == COMPLETION_TRACKING_AUTOMATIC; if (empty($data->{'completionminattemptsenabled' . $suffix}) || !$autocompletion) { $data->{'completionminattempts' . $suffix} = 0; } } } public function validation($data, $files) { $errors = parent::validation($data, $files); // Check open and close times are consistent. if ($data['timeopen'] != 0 && $data['timeclose'] != 0 && $data['timeclose'] < $data['timeopen']) { $errors['timeclose'] = get_string('closebeforeopen', 'quiz'); } // Check that the grace period is not too short. if ($data['overduehandling'] == 'graceperiod') { $graceperiodmin = get_config('quiz', 'graceperiodmin'); if ($data['graceperiod'] <= $graceperiodmin) { $errors['graceperiod'] = get_string('graceperiodtoosmall', 'quiz', format_time($graceperiodmin)); } } $suffix = $this->get_suffix(); $completionminattemptsel = 'completionminattempts' . $suffix; if (!empty($data[$completionminattemptsel])) { if ($data['attempts'] > 0 && $data[$completionminattemptsel] > $data['attempts']) { $completionminattemptsgroupel = 'completionminattemptsgroup' . $suffix; $errors[$completionminattemptsgroupel] = get_string('completionminattemptserror', 'quiz'); } } // Check the boundary value is a number or a percentage, and in range. $i = 0; while (!empty($data['feedbackboundaries'][$i] )) { $boundary = trim($data['feedbackboundaries'][$i]); if (strlen($boundary) > 0) { if ($boundary[strlen($boundary) - 1] == '%') { $boundary = trim(substr($boundary, 0, -1)); if (is_numeric($boundary)) { $boundary = $boundary * $data['grade'] / 100.0; } else { $errors["feedbackboundaries[$i]"] = get_string('feedbackerrorboundaryformat', 'quiz', $i + 1); } } else if (!is_numeric($boundary)) { $errors["feedbackboundaries[$i]"] = get_string('feedbackerrorboundaryformat', 'quiz', $i + 1); } } if (is_numeric($boundary) && $boundary <= 0 || $boundary >= $data['grade'] ) { $errors["feedbackboundaries[$i]"] = get_string('feedbackerrorboundaryoutofrange', 'quiz', $i + 1); } if (is_numeric($boundary) && $i > 0 && $boundary >= $data['feedbackboundaries'][$i - 1]) { $errors["feedbackboundaries[$i]"] = get_string('feedbackerrororder', 'quiz', $i + 1); } $data['feedbackboundaries'][$i] = $boundary; $i += 1; } $numboundaries = $i; // Check there is nothing in the remaining unused fields. if (!empty($data['feedbackboundaries'])) { for ($i = $numboundaries; $i < count($data['feedbackboundaries']); $i += 1) { if (!empty($data['feedbackboundaries'][$i] ) && trim($data['feedbackboundaries'][$i] ) != '') { $errors["feedbackboundaries[$i]"] = get_string('feedbackerrorjunkinboundary', 'quiz', $i + 1); } } } for ($i = $numboundaries + 1; $i < count($data['feedbacktext']); $i += 1) { if (!empty($data['feedbacktext'][$i]['text']) && trim($data['feedbacktext'][$i]['text'] ) != '') { $errors["feedbacktext[$i]"] = get_string('feedbackerrorjunkinfeedback', 'quiz', $i + 1); } } // If CBM is involved, don't show the warning for grade to pass being larger than the maximum grade. if (($data['preferredbehaviour'] == 'deferredcbm') OR ($data['preferredbehaviour'] == 'immediatecbm')) { unset($errors['gradepass']); } // Any other rule plugins. $errors = access_manager::validate_settings_form_fields($errors, $data, $files, $this); return $errors; } /** * Display module-specific activity completion rules. * Part of the API defined by moodleform_mod * @return array Array of string IDs of added items, empty array if none */ public function add_completion_rules() { $mform = $this->_form; $suffix = $this->get_suffix(); $group = []; $completionminattemptsenabledel = 'completionminattemptsenabled' . $suffix; $group[] = $mform->createElement( 'checkbox', $completionminattemptsenabledel, '', get_string('completionminattempts', 'quiz') ); $completionminattemptsel = 'completionminattempts' . $suffix; $group[] = $mform->createElement('text', $completionminattemptsel, '', ['size' => 3]); $mform->setType($completionminattemptsel, PARAM_INT); $completionminattemptsgroupel = 'completionminattemptsgroup' . $suffix; $mform->addGroup($group, $completionminattemptsgroupel, '', ' ', false); $mform->hideIf($completionminattemptsel, $completionminattemptsenabledel, 'notchecked'); return [$completionminattemptsgroupel]; } /** * Add completion grading elements to the form and return the list of element ids. * * @return array Array of string IDs of added items, empty array if none */ public function add_completiongrade_rules(): array { $mform = $this->_form; $suffix = $this->get_suffix(); $completionattemptsexhaustedel = 'completionattemptsexhausted' . $suffix; $mform->addElement( 'advcheckbox', $completionattemptsexhaustedel, null, get_string('completionattemptsexhausted', 'quiz'), ['group' => 'cattempts', 'parentclass' => 'ms-4'] ); $completionpassgradeel = 'completionpassgrade' . $suffix; $mform->hideIf($completionattemptsexhaustedel, $completionpassgradeel, 'notchecked'); $mform->hideIf($completionattemptsexhaustedel, $completionpassgradeel, 'notchecked'); return [$completionattemptsexhaustedel]; } /** * Called during validation. Indicates whether a module-specific completion rule is selected. * * @param array $data Input data (not yet validated) * @return bool True if one or more rules is enabled, false if none are. */ public function completion_rule_enabled($data) { $suffix = $this->get_suffix(); return !empty($data['completionattemptsexhausted' . $suffix]) || !empty($data['completionminattemptsenabled' . $suffix]); } /** * Get the maximum number of attempts that anyone might have due to a user * or group override. Used to decide whether disabledIf rules should be applied. * @return int the number of attempts allowed. For the purpose of this method, * unlimited is returned as 1000, not 0. */ public function get_max_attempts_for_any_override() { global $DB; if (empty($this->_instance)) { // Quiz not created yet, so no overrides. return 1; } if ($this->maxattemptsanyoverride === null) { $this->maxattemptsanyoverride = $DB->get_field_sql(" SELECT MAX(CASE WHEN attempts = 0 THEN 1000 ELSE attempts END) FROM {quiz_overrides} WHERE quiz = ?", [$this->_instance]); if ($this->maxattemptsanyoverride < 1) { // This happens when no override alters the number of attempts. $this->maxattemptsanyoverride = 1; } } return $this->maxattemptsanyoverride; } } PK9B\N autosave.ajax.phpnu[PK9B\ׂ  overridedelete.phpnu[PK9B\cЗwCjCj styles.cssnu[PK9B\ Crenderer.phpnu[PK9B\&?2?2db/services.phpnu[PK9B\.{db/subplugins.jsonnu[PK9B\`Zt  db/log.phpnu[PK9B\9vhh db/tasks.phpnu[PK9B\tr   db/access.phpnu[PK9B\ db/upgrade.phpnu[PK9B\'NVVdb/install.xmlnu[PK9B\{Nclasses/external/delete_overrides.phpnu[PK9B\n,Zclasses/external/update_filter_condition.phpnu[PK9B\W+h h /Bkclasses/external/get_edit_grading_page_data.phpnu[PK9B\^v# xclasses/external/save_overrides.phpnu[PK9B\6 classes/privacy/helper.phpnu[PK9B\J&&,classes/privacy/quizaccess_user_provider.phpnu[PK9B\AMeggclasses/privacy/provider.phpnu[PK9B\1fI . classes/privacy/legacy_quizaccess_polyfill.phpnu[PK9B\ ' classes/privacy/quizaccess_provider.phpnu[PK9B\M?]]  classes/dates.phpnu[PK9B\!K&Y classes/navigation/views/secondary.phpnu[PK9B\C55"3( classes/local/access_rule_base.phpnu[PK9B\<?'n^ classes/local/structure/slot_random.phpnu[PK9B\5= %| classes/local/reports/report_base.phpnu[PK9B\\!}}/ classes/local/reports/attempts_report_table.phpnu[PK9B\c776 classes/local/reports/attempts_report_options_form.phpnu[PK9B\Η((1! classes/local/reports/attempts_report_options.phpnu[PK9B\>oTATA),J classes/local/reports/attempts_report.phpnu[PK9B\ܖ*?? ً classes/local/override_cache.phpnu[PK9B\^^"h classes/local/override_manager.phpnu[PK9B\2,  classes/group_observers.phpnu[PK9B\HGYY classes/quiz_settings.phpnu[PK9B\lNi^^8b classes/grade_calculator.phpnu[PK9B\Fz--L classes/notification_helper.phpnu[PK9B\]ă!/ classes/plugininfo/quizaccess.phpnu[PK9B\+Ƀ classes/plugininfo/quiz.phpnu[PK9B\YY*O classes/admin/overdue_handling_setting.phpnu[PK9B\G||* classes/admin/browser_security_setting.phpnu[PK9B\Q|VV$ classes/admin/user_image_setting.phpnu[PK9B\Ѵ;;& classes/admin/grade_method_setting.phpnu[PK9B\<))  classes/admin/review_setting.phpnu[PK9B\j) classes/search/activity.phpnu[PK9B\ &[/ classes/hook/attempt_state_changed.phpnu[PK9B\DĬ#: classes/hook/structure_modified.phpnu[PK9B\;d~d~A classes/quiz_attempt.phpnu[PK9B\  +S classes/output/navigation_panel_attempt.phpnu[PK9B\G>bA7A7. classes/output/attempt_summary_information.phpnu[PK9B\v%Uclasses/output/renderer.phpnu[PK9B\}D&classes/output/grades/grade_out_of.phpnu[PK9B\L @*classes/output/navigation_panel_review.phpnu[PK9B\Iy# classes/output/question_chooser.phpnu[PK9B\ classes/output/edit_renderer.phpnu[PK9B\yY}classes/output/view_page.phpnu[PK9B\'-O classes/output/navigation_question_button.phpnu[PK9B\JL-classes/output/navigation_section_heading.phpnu[PK9B\\sr3 3 #classes/output/list_of_attempts.phpnu[PK9B\Au*<%classes/output/links_to_other_attempts.phpnu[PK9B\=hh$+classes/output/edit_grading_page.phpnu[PK9B\y(=>classes/output/navigation_panel_base.phpnu[PK9B\pF#{ { #\classes/output/edit_nav_actions.phpnu[PK9B\ʹ$fgclasses/output/overrides_actions.phpnu[PK9B\0Fwclasses/repaginate.phpnu[PK9B\rb 'classes/event/attempt_becameoverdue.phpnu[PK9B\(G  $classes/event/page_break_created.phpnu[PK9B\AT. . )[classes/event/quiz_grade_item_created.phpnu[PK9B\tH5 #classes/event/attempt_abandoned.phpnu[PK9B\ G G 'classes/event/user_override_updated.phpnu[PK9B\I$G G classes/event/attempt_graded.phpnu[PK9B\GC{t "Tclasses/event/edit_page_viewed.phpnu[PK9B\ 0 0 #Wclasses/event/attempt_submitted.phpnu[PK9B\% classes/event/slot_created.phpnu[PK9B\ko%%#classes/event/attempt_autosaved.phpnu[PK9B\:f”: : #Jclasses/event/slot_mark_updated.phpnu[PK9B\m< < 2classes/event/attempt_manual_grading_completed.phpnu[PK9B\ (u)classes/event/group_override_deleted.phpnu[PK9B\B | '6classes/event/section_break_deleted.phpnu[PK9B\3D "Dclasses/event/attempt_reopened.phpnu[PK9B\+ )Oclasses/event/slot_grade_item_updated.phpnu[PK9B\h !\classes/event/attempt_started.phpnu[PK9B\2" " $hclasses/event/page_break_deleted.phpnu[PK9B\ hi  "uclasses/event/attempt_regraded.phpnu[PK9B\/ (тclasses/event/group_override_updated.phpnu[PK9B\| *classes/event/question_manually_graded.phpnu[PK9B\ (7classes/event/group_override_created.phpnu[PK9B\!TT&aclasses/event/course_module_viewed.phpnu[PK9B\[  classes/event/report_viewed.phpnu[PK9B\ghF F '?classes/event/user_override_created.phpnu[PK9B\,classes/event/slot_displaynumber_updated.phpnu[PK9B\&. . )8classes/event/quiz_grade_item_deleted.phpnu[PK9B\p classes/event/attempt_viewed.phpnu[PK9B\qbclasses/event/slot_moved.phpnu[PK9B\|Y Y )classes/event/attempt_preview_started.phpnu[PK9B\M1,N N ' classes/event/user_override_deleted.phpnu[PK9B\S>* ;classes/question/qubaids_for_quiz_user.phpnu[PK9B\ $Bclasses/question/display_options.phpnu[PK9B\ޮ.{Tclasses/question/bank/question_name_column.phpnu[PK9B\ywCC&]classes/question/bank/qbank_helper.phpnu[PK9B\I4= = 3classes/question/bank/question_name_text_column.phpnu[PK9B\[+Eclasses/question/bank/add_action_column.phpnu[PK9B\ qhh/cclasses/question/bank/preview_action_column.phpnu[PK9B\Tpp7*classes/question/bank/quiz_managecategories_feature.phpnu[PK9B\W^.classes/question/bank/random_question_view.phpnu[PK9B\v44%,classes/question/bank/custom_view.phpnu[PK9B\>OЩAclasses/question/bank/filter/custom_category_condition_helper.phpnu[PK9B\t':Jclasses/question/bank/filter/custom_category_condition.phpnu[PK9B\;Ŧ /classes/question/qubaids_for_users_attempts.phpnu[PK9B\%&classes/question/qubaids_for_quiz.phpnu[PK9B\8z(m.classes/completion/custom_completion.phpnu[PK9B\ewdSdSEclasses/access_manager.phpnu[PK9B\/classes/analytics/indicator/cognitive_depth.phpnu[PK9B\^zuu-classes/analytics/indicator/activity_base.phpnu[PK9B\#.}classes/analytics/indicator/social_breadth.phpnu[PK9B\La%4classes/adminpresets/adminpresets_review_setting.phpnu[PK9B\qs>|classes/adminpresets/adminpresets_overdue_handling_setting.phpnu[PK9B\oll:kclasses/adminpresets/adminpresets_grade_method_setting.phpnu[PK9B\y8Aclasses/adminpresets/adminpresets_user_image_setting.phpnu[PK9B\ _yy>+classes/adminpresets/adminpresets_browser_security_setting.phpnu[PK9B\e3ssclasses/cache/overrides.phpnu[PK9B\ʯclasses/structure.phpnu[PK9B\D ,templates/modal_add_random_question.mustachenu[PK9B\MҪ #templates/list_of_attempts.mustachenu[PK9B\#templates/edit_nav_actions.mustachenu[PK9B\n* templates/submission_confirmation.mustachenu[PK9B\aYp $9templates/overrides_actions.mustachenu[PK9B\%0 templates/switch_bank_header.mustachenu[PK9B\54+'&templates/add_random_question_form.mustachenu[PK9B\X."#<templates/question_chooser.mustachenu[PK9B\?/~@templates/update_filter_condition_form.mustachenu[PK9B\|{,Ftemplates/random_question_container.mustachenu[PK9B\xz=Jtemplates/random_question_form_preview_question_list.mustachenu[PK9B\P7 0Rtemplates/question_slot.mustachenu[PK9B\N>`templates/timer.mustachenu[PK9B\ ]u$oetemplates/edit_grading_page.mustachenu[PK9B\_ś.templates/attempt_summary_information.mustachenu[PK9B\(tO&O& ƌedit_rest.phpnu[PK9B\o|44Raccessmanager_form.phpnu[PK9B\ZXX ̷upgrade.txtnu[PK9B\hrۇRR locallib.phpnu[PK9B\j ccronlib.phpnu[PK9B\r4hreviewquestion.phpnu[PK9B\3== zmodule.jsnu[PK9B\h review.phpnu[PK9B\b_2_2 overrides.phpnu[PK9B\cq,xaccessrule/seb/db/services.phpnu[PK9B\YFaccessrule/seb/db/install.phpnu[PK9B\X'"'"accessrule/seb/db/access.phpnu[PK9B\Yg' ' 6accessrule/seb/db/upgrade.phpnu[PK9B\yәAaccessrule/seb/db/install.xmlnu[PK9B\wo PZaccessrule/seb/db/caches.phpnu[PK9B\MJJ)aaccessrule/seb/lang/en/quizaccess_seb.phpnu[PK9B\kH]accessrule/seb/backup/moodle2/restore_quizaccess_seb_subplugin.class.phpnu[PK9B\%Y'Ggaccessrule/seb/backup/moodle2/backup_quizaccess_seb_subplugin.class.phpnu[PK9B\4accessrule/seb/config.phpnu[PK9B\*#{M M ~accessrule/seb/lib.phpnu[PK9B\y+#ggaccessrule/seb/version.phpnu[PK9B\{i6accessrule/seb/classes/external/validate_quiz_keys.phpnu[PK9B\|4..+accessrule/seb/classes/privacy/provider.phpnu[PK9B\;u@,,.'accessrule/seb/classes/local/form/template.phpnu[PK9B\M%4E7accessrule/seb/classes/local/table/template_list.phpnu[PK9B\aD+(+(._Kaccessrule/seb/classes/template_controller.phpnu[PK9B\d &saccessrule/seb/classes/hideif_rule.phpnu[PK9B\nb(WW!accessrule/seb/classes/helper.phpnu[PK9B\FTjcc)accessrule/seb/classes/link_generator.phpnu[PK9B\8VN N %taccessrule/seb/classes/config_key.phpnu[PK9B\2D``,accessrule/seb/classes/settings_provider.phpnu[PK9B\qU22-Taccessrule/seb/classes/seb_access_manager.phpnu[PK9B\1accessrule/seb/classes/event/template_created.phpnu[PK9B\/DJ2accessrule/seb/classes/event/template_disabled.phpnu[PK9B\ @1accessrule/seb/classes/event/template_enabled.phpnu[PK9B\"W 1accessrule/seb/classes/event/template_deleted.phpnu[PK9B\UU1accessrule/seb/classes/event/access_prevented.phpnu[PK9B\{HΗ1accessrule/seb/classes/event/template_updated.phpnu[PK9B\aV5Z5Z,accessrule/seb/classes/seb_quiz_settings.phpnu[PK9B\m@@()?accessrule/seb/classes/property_list.phpnu[PK9B\ohYY#accessrule/seb/classes/template.phpnu[PK9B\2BFpkk)accessrule/seb/templates/loading.mustachenu[PK9B\7K waccessrule/seb/amd/src/view.jsnu[PK9B\.[accessrule/seb/amd/src/validate_quiz_access.jsnu[PK9B\oN)accessrule/seb/amd/src/managetemplates.jsnu[PK9B\HD 3accessrule/seb/amd/build/managetemplates.min.js.mapnu[PK9B\i8accessrule/seb/amd/build/validate_quiz_access.min.js.mapnu[PK9B\h)/aaccessrule/seb/amd/build/managetemplates.min.jsnu[PK9B\JUm (accessrule/seb/amd/build/view.min.js.mapnu[PK9B\k^ ^ 4accessrule/seb/amd/build/validate_quiz_access.min.jsnu[PK9B\! 7 7 $accessrule/seb/amd/build/view.min.jsnu[PK9B\g2!]!]<accessrule/seb/rule.phpnu[PK9B\O##$`accessrule/seb/tests/helper_test.phpnu[PK9B\D:accessrule/seb/tests/fixtures/JSON_unencrypted_mac_001.txtnu[PK9B\ x{38accessrule/seb/tests/fixtures/simpleunencrypted.sebnu[PK9B\ؙp17175naccessrule/seb/tests/fixtures/unencrypted_mac_001.sebnu[PK9B\צ:= = +accessrule/seb/tests/fixtures/encrypted.sebnu[PK9B\+OO-accessrule/seb/tests/fixtures/unencrypted.sebnu[PK9B\1!P!P59accessrule/seb/tests/fixtures/unencrypted_win_223.sebnu[PK9B\75D accessrule/seb/tests/fixtures/simpleunencryptedwithoutoriginator.sebnu[PK9B\~22,#accessrule/seb/tests/backup_restore_test.phpnu[PK9B\y++"Taccessrule/seb/tests/rule_test.phpnu[PK9B\ ^^&ѝaccessrule/seb/tests/generator/lib.phpnu[PK9B\o\\Aaccessrule/seb/tests/generator/behat_quizaccess_seb_generator.phpnu[PK9B\np'p';Raccessrule/seb/tests/external/validate_quiz_access_test.phpnu[PK9B\9H##.-accessrule/seb/tests/privacy/provider_test.phpnu[PK9B\5]5],.accessrule/seb/tests/access_manager_test.phpnu[PK9B\$$&V accessrule/seb/tests/template_test.phpnu[PK9B\>>+{ accessrule/seb/tests/quiz_settings_test.phpnu[PK9B\$PP/V1!accessrule/seb/tests/settings_provider_test.phpnu[PK9B\YdII($"accessrule/seb/tests/config_key_test.phpnu[PK9B\:s s ,2"accessrule/seb/tests/link_generator_test.phpnu[PK9B\Kp*uPuP+u@"accessrule/seb/tests/property_list_test.phpnu[PK9B\էDw*E"accessrule/seb/tests/event/events_test.phpnu[PK9B\E; /"accessrule/seb/tests/behat/seb_settings.featurenu[PK9B\A"accessrule/seb/tests/behat/secure_layout_activity_heading.featurenu[PK9B\277,"accessrule/seb/tests/behat/edit_form.featurenu[PK9B\WA/4"accessrule/seb/tests/behat/attempt_quiz.featurenu[PK9B\U)d#accessrule/seb/tests/hideif_rule_test.phpnu[PK9B\=M)M)*y #accessrule/seb/tests/test_helper_trait.phpnu[PK9B\`3K޺ 3#accessrule/seb/template.phpnu[PK9B\)lpT T %8#accessrule/seb/settings.phpnu[PK9B\fcttKE#accessrule/delaybetweenattempts/lang/en/quizaccess_delaybetweenattempts.phpnu[PK9B\XUU+K#accessrule/delaybetweenattempts/version.phpnu[PK9B\+BB<cP#accessrule/delaybetweenattempts/classes/privacy/provider.phpnu[PK9B\Y (W#accessrule/delaybetweenattempts/rule.phpnu[PK9B\6c773e#accessrule/delaybetweenattempts/tests/rule_test.phpnu[PK9B\̋{{AT#accessrule/offlineattempts/lang/en/quizaccess_offlineattempts.phpnu[PK9B\TT,,&@#accessrule/offlineattempts/version.phpnu[PK9B\Dw..7ª#accessrule/offlineattempts/classes/privacy/provider.phpnu[PK9B\*  #W#accessrule/offlineattempts/rule.phpnu[PK9B\G8//.#accessrule/offlineattempts/tests/rule_test.phpnu[PK9B\53A#accessrule/password/lang/en/quizaccess_password.phpnu[PK9B\Y11#accessrule/password/version.phpnu[PK9B\K0B#accessrule/password/classes/privacy/provider.phpnu[PK9B\. o o #accessrule/password/rule.phpnu[PK9B\Gː'o#accessrule/password/tests/rule_test.phpnu[PK9B\Q_kkV#accessrule/upgrade.txtnu[PK9B\=#accessrule/openclosedate/lang/en/quizaccess_openclosedate.phpnu[PK9B\|4C@@$#accessrule/openclosedate/version.phpnu[PK9B\ &&5$accessrule/openclosedate/classes/privacy/provider.phpnu[PK9B\`, ! $accessrule/openclosedate/rule.phpnu[PK9B\ӶQ,V$accessrule/openclosedate/tests/rule_test.phpnu[PK9B\S39z5$accessrule/numattempts/lang/en/quizaccess_numattempts.phpnu[PK9B\::":$accessrule/numattempts/version.phpnu[PK9B\j)3?$accessrule/numattempts/classes/privacy/provider.phpnu[PK9B\igE$accessrule/numattempts/rule.phpnu[PK9B\8ْ *L$accessrule/numattempts/tests/rule_test.phpnu[PK9B\u=5V$accessrule/ipaddress/lang/en/quizaccess_ipaddress.phpnu[PK9B\ w44 [$accessrule/ipaddress/version.phpnu[PK9B\f`1g`$accessrule/ipaddress/classes/privacy/provider.phpnu[PK9B\`f$accessrule/ipaddress/rule.phpnu[PK9B\Z$L L (m$accessrule/ipaddress/tests/rule_test.phpnu[PK9B\A;mmw$accessrule/accessrulebase.phpnu[PK9B\;d|$accessrule/securewindow/lang/en/quizaccess_securewindow.phpnu[PK9B\먐==#$accessrule/securewindow/version.phpnu[PK9B\hr 8""4-$accessrule/securewindow/classes/privacy/provider.phpnu[PK9B\½< $accessrule/securewindow/rule.phpnu[PK9B\`x+$accessrule/securewindow/tests/rule_test.phpnu[PK9B\g5j$accessrule/timelimit/lang/en/quizaccess_timelimit.phpnu[PK9B\کUK?? m$accessrule/timelimit/version.phpnu[PK9B\a_1$accessrule/timelimit/classes/privacy/provider.phpnu[PK9B\A[_" s$accessrule/timelimit/rule.phpnu[PK9B\($accessrule/timelimit/tests/rule_test.phpnu[PK9B\+Wee$edit.phpnu[PK9B\$yui/src/autosave/build.jsonnu[PK9B\zE#$yui/src/autosave/meta/autosave.jsonnu[PK9B\DP88$yui/src/autosave/js/autosave.jsnu[PK9B\@a(%yui/src/dragdrop/build.jsonnu[PK9B\&#)%yui/src/dragdrop/meta/dragdrop.jsonnu[PK9B\`??+%yui/src/dragdrop/js/dragdrop.jsnu[PK9B\mMU/%yui/src/dragdrop/js/resource.jsnu[PK9B\z)l&l&rN%yui/src/dragdrop/js/section.jsnu[PK9B\BAA,u%yui/src/util/build.jsonnu[PK9B\kU%%v%yui/src/util/meta/util.jsonnu[PK9B\ja88$y%yui/src/util/js/slot.jsnu[PK9B\לM%yui/src/util/js/base.jsnu[PK9B\R((g%yui/src/util/js/page.jsnu[PK9B\Ǣ"1%yui/src/questionchooser/build.jsonnu[PK9B\^@1O%yui/src/questionchooser/meta/questionchooser.jsonnu[PK9B\^ -r%yui/src/questionchooser/js/questionchooser.jsnu[PK9B\d_%yui/src/quizbase/build.jsonnu[PK9B\Bnss#%yui/src/quizbase/meta/quizbase.jsonnu[PK9B\0m  %yui/src/quizbase/js/quizbase.jsnu[PK9B\*%yui/src/toolboxes/build.jsonnu[PK9B\n~;;%(%yui/src/toolboxes/meta/toolboxes.jsonnu[PK9B\/XUXU %yui/src/toolboxes/js/resource.jsnu[PK9B\;88`T&yui/src/toolboxes/js/section.jsnu[PK9B\VFBB\&yui/src/toolboxes/js/toolbox.jsnu[PK9B\2Dn&yui/src/modform/build.jsonnu[PK9B\ gH=!&yui/src/modform/meta/modform.jsonnu[PK9B\nm\ppì&yui/src/modform/js/modform.jsnu[PK9B\ %hhF&yui/build/moodle-mod_quiz-util-base/moodle-mod_quiz-util-base-debug.jsnu[PK9B\ %hh@^&yui/build/moodle-mod_quiz-util-base/moodle-mod_quiz-util-base.jsnu[PK9B\LIddD6&yui/build/moodle-mod_quiz-util-base/moodle-mod_quiz-util-base-min.jsnu[PK9B\4))@&yui/build/moodle-mod_quiz-util-page/moodle-mod_quiz-util-page.jsnu[PK9B\4))F&yui/build/moodle-mod_quiz-util-page/moodle-mod_quiz-util-page-debug.jsnu[PK9B\[I D 'yui/build/moodle-mod_quiz-util-page/moodle-mod_quiz-util-page-min.jsnu[PK9B\퀬@a'yui/build/moodle-mod_quiz-toolboxes/moodle-mod_quiz-toolboxes.jsnu[PK9B\퀬FQ'yui/build/moodle-mod_quiz-toolboxes/moodle-mod_quiz-toolboxes-debug.jsnu[PK9B\@x"7"7DGq(yui/build/moodle-mod_quiz-toolboxes/moodle-mod_quiz-toolboxes-min.jsnu[PK9B\#_MMPݨ(yui/build/moodle-mod_quiz-questionchooser/moodle-mod_quiz-questionchooser-min.jsnu[PK9B\; R(yui/build/moodle-mod_quiz-questionchooser/moodle-mod_quiz-questionchooser-debug.jsnu[PK9B\; Lӹ(yui/build/moodle-mod_quiz-questionchooser/moodle-mod_quiz-questionchooser.jsnu[PK9B\:;(wwD(yui/build/moodle-mod_quiz-quizbase/moodle-mod_quiz-quizbase-debug.jsnu[PK9B\:;(ww>(yui/build/moodle-mod_quiz-quizbase/moodle-mod_quiz-quizbase.jsnu[PK9B\YB(yui/build/moodle-mod_quiz-quizbase/moodle-mod_quiz-quizbase-min.jsnu[PK9B\W7J7JD(yui/build/moodle-mod_quiz-dragdrop/moodle-mod_quiz-dragdrop-debug.jsnu[PK9B\(jGG>{8)yui/build/moodle-mod_quiz-dragdrop/moodle-mod_quiz-dragdrop.jsnu[PK9B\JB̀)yui/build/moodle-mod_quiz-dragdrop/moodle-mod_quiz-dragdrop-min.jsnu[PK9B\r @)yui/build/moodle-mod_quiz-modform/moodle-mod_quiz-modform-min.jsnu[PK9B\02B})yui/build/moodle-mod_quiz-modform/moodle-mod_quiz-modform-debug.jsnu[PK9B\02<ԣ)yui/build/moodle-mod_quiz-modform/moodle-mod_quiz-modform.jsnu[PK9B\?D%)yui/build/moodle-mod_quiz-util-slot/moodle-mod_quiz-util-slot-min.jsnu[PK9B\Yi@99F)yui/build/moodle-mod_quiz-util-slot/moodle-mod_quiz-util-slot-debug.jsnu[PK9B\Yi@99@D)yui/build/moodle-mod_quiz-util-slot/moodle-mod_quiz-util-slot.jsnu[PK9B\>>B1*yui/build/moodle-mod_quiz-autosave/moodle-mod_quiz-autosave-min.jsnu[PK9B\acpW44>F*yui/build/moodle-mod_quiz-autosave/moodle-mod_quiz-autosave.jsnu[PK9B\3&9&9D{*yui/build/moodle-mod_quiz-autosave/moodle-mod_quiz-autosave-debug.jsnu[PK9B\= q*report.phpnu[PK9B\P*%55*attemptlib.phpnu[PK9B\Bj\\ +*index.phpnu[PK9B\%z*repaginate.phpnu[PK9B\  "*amd/src/submission_confirmation.jsnu[PK9B\cގK*amd/src/preflightcheck.jsnu[PK9B\43##'"+amd/src/random_question_form_preview.jsnu[PK9B\ 2%+amd/src/update_random_question_filter_condition.jsnu[PK9B\3jjw/+amd/src/repaginate.jsnu[PK9B\TT&5+amd/src/question_slot.jsnu[PK9B\``=+amd/src/add_random_form.jsnu[PK9B\~$@$@l\+amd/src/edit_multiple_grades.jsnu[PK9B\Eߜ+amd/src/add_question_modal.jsnu[PK9B\(QQ$&+amd/src/modal_add_random_question.jsnu[PK9B\x8 ,amd/src/reopen_attempt_ui.jsnu[PK9B\%hA"A"# ,amd/src/modal_quiz_question_bank.jsnu[PK9B\s>"z z -}/,amd/build/random_question_form_preview.min.jsnu[PK9B\{,T;,amd/build/submission_confirmation.min.js.mapnu[PK9B\6U U "UJ,amd/build/question_slot.min.js.mapnu[PK9B\dv--*U,amd/build/modal_add_random_question.min.jsnu[PK9B\XU%%$,amd/build/add_random_form.min.js.mapnu[PK9B\G>nn.,amd/build/modal_add_random_question.min.js.mapnu[PK9B\x[..(j-amd/build/submission_confirmation.min.jsnu[PK9B\>N' -amd/build/add_question_modal.min.js.mapnu[PK9B\"WY%9-amd/build/repaginate.min.jsnu[PK9B\E/R.R.-<-amd/build/modal_quiz_question_bank.min.js.mapnu[PK9B\$ĸ #3k-amd/build/add_question_modal.min.jsnu[PK9B\Qc;;#>x-amd/build/preflightcheck.min.js.mapnu[PK9B\u944&̏-amd/build/reopen_attempt_ui.min.js.mapnu[PK9B\tv3--1V-amd/build/random_question_form_preview.min.js.mapnu[PK9B\aYH..)-amd/build/modal_quiz_question_bank.min.jsnu[PK9B\Y(8L-amd/build/update_random_question_filter_condition.min.jsnu[PK9B\9ٔ-amd/build/question_slot.min.jsnu[PK9B\Q##%-amd/build/edit_multiple_grades.min.jsnu[PK9B\$k` .amd/build/add_random_form.min.jsnu[PK9B\~ ~ <.amd/build/update_random_question_filter_condition.min.js.mapnu[PK9B\ZZ)*.amd/build/edit_multiple_grades.min.js.mapnu[PK9B\"$.amd/build/reopen_attempt_ui.min.jsnu[PK9B\A.amd/build/preflightcheck.min.jsnu[PK9B\x__5.amd/build/repaginate.min.js.mapnu[PK9B\rj .UPGRADING.mdnu[PK9B\ l.override_form.phpnu[PK9B\-u v.comment.phpnu[PK9B\懂e.tests/fixtures/moodle_logo.jpgnu[PK9B\(VPP5.tests/fixtures/questions01.csvnu[PK9B\2еkk.tests/fixtures/results00.csvnu[PK9B\"VV%.tests/fixtures/random_by_tag_quiz.mbznu[PK9B\+AA5.tests/fixtures/quizzes.csvnu[PK9B\h g  .tests/fixtures/questions00.csvnu[PK9B\ )ll.tests/fixtures/steps01.csvnu[PK9B\5G.tests/fixtures/moodle_39_quiz_with_random_question_from_mod_context.mbznu[PK9B\'002.tests/fixtures/question_attempts_missing_users.mbznu[PK9B\CLL":(/tests/fixtures/moodle_311_quiz.mbznu[PK9B\# ;/tests/fixtures/results01.csvnu[PK9B\9>"#0tests/backup/restore_date_test.phpnu[PK9B\P 0tests/backup/restore_39_test.phpnu[PK9B\ {0gg.0tests/backup/restore_quiz_grade_items_test.phpnu[PK9B\& ;;P0tests/generator/lib.phpnu[PK9B\ c ,/1tests/generator/behat_mod_quiz_generator.phpnu[PK9B\AqT*T*91tests/repaginate_test.phpnu[PK9B\@'44#]d1tests/external/grade_items_test.phpnu[PK9B\5U!gg/1tests/external/update_filter_condition_test.phpnu[PK9B\=&m1tests/external/reopen_attempt_test.phpnu[PK9B\p$1tests/external/update_slots_test.phpnu[PK9B\D?? 1tests/external/override_test.phpnu[PK9B\m>= 61tests/external/external_test.phpnu[PK9B\|'D S SG3tests/privacy/provider_test.phpnu[PK9B\=5:4tests/quizobj_test.phpnu[PK9B\r%O4tests/local/override_manager_test.phpnu[PK9B\x$` ` #N5tests/local/override_cache_test.phpnu[PK9B\$nH߇%% 5tests/reportlib_test.phpnu[PK9B\r|$|$25tests/qbank_helper_test.phpnu[PK9B\wW5tests/restore_override_test.phpnu[PK9B\ auf5tests/lib_test.phpnu[PK9B\e_1_1;@6tests/quiz_notify_attempt_manual_grading_completed_test.phpnu[PK9B\u@=+ N6tests/attempt_walkthrough_from_csv_test.phpnu[PK9B\j"TS6tests/attempt_walkthrough_test.phpnu[PK9B\Cl%l%&6tests/quiz_question_bank_view_test.phpnu[PK9B\"$#  ,N%7tests/classes/question_helper_test_trait.phpnu[PK9B\VgYAYA.E7tests/classes/attempt_walkthrough_testcase.phpnu[PK9B\^c;wwc7tests/locallib_test.phpnu[PK9B\ $ $)7tests/quiz_question_restore_test.phpnu[PK9B\"5$[J[J&8tests/calendar_event_modified_test.phpnu[PK9B\4;:U:U 48tests/custom_completion_test.phpnu[PK9B\PR99tests/tags_test.phpnu[PK9B\`H r1O9tests/output/attempt_summary_information_test.phpnu[PK9B\)($o~o~k9tests/attempt_test.phpnu[PK9B\ʔ9tests/dates_test.phpnu[PK9B\{&q//$:tests/quiz_question_version_test.phpnu[PK9B\L::"7:tests/notification_helper_test.phpnu[PK9B\01((3r:tests/event/events_test.phpnu[PK9B\|-TT&N;tests/task/precreate_attempts_test.phpnu[PK9B\7%';tests/question/display_options_test.phpnu[PK9B\'1 00*;tests/local_structure_slot_random_test.phpnu[PK9B\s;tests/attempts_test.phpnu[PK9B\f'1ݞ<tests/privacy_legacy_quizaccess_polyfill_test.phpnu[PK9B\)غ<tests/quiz_question_helper_test_trait.phpnu[PK9B\",<tests/generator_test.phpnu[PK9B\)a445<tests/behat/editing_remove_multiple_questions.featurenu[PK9B\ 3 =tests/behat/quiz_activity_completion_locked.featurenu[PK9B\awn6=tests/behat/completion_condition_attempts_used.featurenu[PK9B\ԅ!'=tests/behat/attempt_begin.featurenu[PK9B\R1B"'D=tests/behat/flag_questions.featurenu[PK9B\0TT)QS=tests/behat/attempt_secure_layout.featurenu[PK9B\ύ.,[=tests/behat/settings_form_validation.featurenu[PK9B\i(]ii*w`=tests/behat/quiz_question_versions.featurenu[PK9B\7{{!:}=tests/behat/quiz_feedback.featurenu[PK9B\7>#=tests/behat/attempt_invalid.featurenu[PK9B\+6161*\=tests/behat/attempt_redo_questions.featurenu[PK9B\[Vd4 4 =tests/behat/backup.featurenu[PK9B\3n+j=tests/behat/editing_remove_question.featurenu[PK9B\h.V=tests/behat/editing_question_numbering.featurenu[PK9B\TIf#n>tests/behat/random_question.featurenu[PK9B\|C,%>tests/behat/quiz_activity_completion.featurenu[PK9B\ w:>tests/behat/group_mode.featurenu[PK9B\PZ 3J>tests/behat/quiz_interactive_multiple_tries.featurenu[PK9B\MKMK,JX>tests/behat/editing_section_headings.featurenu[PK9B\ZM*>tests/behat/manually_mark_question.featurenu[PK9B\}UU3>tests/behat/editing_set_marks_with_attempts.featurenu[PK9B\LΙzz'>tests/behat/quiz_attempt_blocks.featurenu[PK9B\5u>tests/behat/quiz_activity_completion_unlocked.featurenu[PK9B\yۗc*n>tests/behat/attempt_review_options.featurenu[PK9B\T9n6>tests/behat/completion_condition_passing_grade.featurenu[PK9B\PS‡-?tests/behat/view_grade_recover_grades.featurenu[PK9B\&cII-?tests/behat/feedback_on_override_mark.featurenu[PK9B\eM.v?tests/behat/quiz_activity_availability.featurenu[PK9B\Β8833,6?tests/behat/attempt_require_previous.featurenu[PK9B\O0nkk&j?tests/behat/attempt_timed_quiz.featurenu[PK9B\O+y?tests/behat/quiz_activity_certainty.featurenu[PK9B\n1?tests/behat/editing_set_marks_no_attempts.featurenu[PK9B\nr r +ͥ?tests/behat/editing_multiple_grades.featurenu[PK9B\  9?tests/behat/completion_condition_minimum_attempts.featurenu[PK9B\Kqq2?tests/behat/settings_form_fields_disableif.featurenu[PK9B\Әgy5y5,?tests/behat/editing_require_previous.featurenu[PK9B\ξ  )@tests/behat/info_page.featurenu[PK9B\Q6//&4@tests/behat/quiz_user_override.featurenu[PK9B\=~+~+'e@tests/behat/quiz_group_override.featurenu[PK9B\ն]5 5 '@tests/behat/editing_switch_bank.featurenu[PK9B\Z$a?a?2x@tests/behat/editing_add_from_question_bank.featurenu[PK9B\+)''&;@tests/behat/attempt_sequential.featurenu[PK9B\)Aj-j-!@tests/behat/attempt_basic.featurenu[PK9B\^::&sAtests/behat/editing_repaginate.featurenu[PK9B\4 XAtests/behat/quiz_reset.featurenu[PK9B\ /dAtests/behat/quiz_single_activity_course.featurenu[PK9B\++qAtests/behat/editing_add.featurenu[PK9B\p1+Atests/behat/quiz_no_calendar_capabilities.featurenu[PK9B\m MM'oAtests/behat/editing_edit_random.featurenu[PK9B\ f+Atests/behat/attempt_multiple_grades.featurenu[PK9B\@#xx)]Atests/behat/editing_move_by_click.featurenu[PK9B\VB B ".Atests/behat/reattempt_quiz.featurenu[PK9B\8 _ _ Atests/behat/add_quiz.featurenu[PK9B\}}mAtests/behat/behat_mod_quiz.phpnu[PK9B\S//%8Btests/behat/completion_manual.featurenu[PK9B\\ғ]Btests/behat/preview.featurenu[PK9B\wC44&Btests/behat/editing_add_random.featurenu[PK9B\-HIICtests/restore_attempt_test.phpnu[PK9B\Yl+Ctests/structure_test.phpnu[PK9B\,7+1+1Cdeprecatedlib.phpnu[PK9B\o|44!Dreport/attemptsreport.phpnu[PK9B\o|44Dreport/default.phpnu[PK9B\ >> Dreport/reportlib.phpnu[PK9B\_gIIDreport/overview/db/install.phpnu[PK9B\..NDreport/overview/db/upgrade.phpnu[PK9B\m<<CUDreport/overview/db/install.xmlnu[PK9B\)\Dreport/overview/lang/en/quiz_overview.phpnu[PK9B\4 $rDreport/overview/overview_options.phpnu[PK9B\caDDreport/overview/version.phpnu[PK9B\3<<"Dreport/overview/overview_table.phpnu[PK9B\(G ,ڿDreport/overview/classes/privacy/provider.phpnu[PK9B\JJ5 Dreport/overview/templates/regrade_modal_body.mustachenu[PK9B\EvttDreport/overview/report.phpnu[PK9B\WS(QEreport/overview/amd/src/regrade_modal.jsnu[PK9B\%Q2VcEreport/overview/amd/build/regrade_modal.min.js.mapnu[PK9B\;,D D .}Ereport/overview/amd/build/regrade_modal.min.jsnu[PK9B\\'!CEreport/overview/tests/helpers.phpnu[PK9B\՜XX/0Ereport/overview/tests/privacy/provider_test.phpnu[PK9B\`2 2Ereport/overview/tests/behat/reopen_attempt.featurenu[PK9B\ RzEzE-̫Ereport/overview/tests/behat/regrading.featurenu[PK9B\+R R :Ereport/overview/tests/behat/regrading_not_possible.featurenu[PK9B\ 2_Ereport/overview/tests/behat/attempt_states.featurenu[PK9B\Jnޭ)Freport/overview/tests/behat/basic.featurenu[PK9B\Qع? ? 8Freport/overview/tests/behat/multiple_grade_items.featurenu[PK9B\hzMM%S'Freport/overview/tests/report_test.phpnu[PK9B\(|PP!PuFreport/overview/overview_form.phpnu[PK9B\o|44|Freport/attemptsreport_form.phpnu[PK9B\^ ZsFreport/upgrade.txtnu[PK9B\o|44Freport/attemptsreport_table.phpnu[PK9B\**1)Freport/responses/first_or_all_responses_table.phpnu[PK9B\@ՒFreport/responses/db/install.phpnu[PK9B\c c +hFreport/responses/lang/en/quiz_responses.phpnu[PK9B\9RC &Freport/responses/version.phpnu[PK9B\6$ #lFreport/responses/responses_form.phpnu[PK9B\ed;ff-Freport/responses/classes/privacy/provider.phpnu[PK9B\&jFreport/responses/responses_options.phpnu[PK9B\(__ Greport/responses/report.phpnu[PK9B\8"--+L+Greport/responses/tests/fixtures/quizzes.csvnu[PK9B\P vv/+Greport/responses/tests/fixtures/questions00.csvnu[PK9B\ m-d/.Greport/responses/tests/fixtures/responses00.csvnu[PK9B\- +3Greport/responses/tests/fixtures/steps00.csvnu[PK9B\f :``0 ;Greport/responses/tests/privacy/provider_test.phpnu[PK9B\1J*dii*MGreport/responses/tests/behat/basic.featurenu[PK9B\FI@\Greport/responses/tests/responses_from_steps_walkthrough_test.phpnu[PK9B\Kc  )sGreport/responses/last_responses_table.phpnu[PK9B\o|44!Greport/attemptsreport_options.phpnu[PK9B\1((Greport/grading/styles.cssnu[PK9B\8FGreport/grading/renderer.phpnu[PK9B\9ewGreport/grading/db/install.phpnu[PK9B\SAA)Greport/grading/db/access.phpnu[PK9B\ ~~'Greport/grading/lang/en/quiz_grading.phpnu[PK9B\IGreport/grading/version.phpnu[PK9B\= +Greport/grading/classes/privacy/provider.phpnu[PK9B\ҍttGreport/grading/report.phpnu[PK9B\ Wnn'QHreport/grading/gradingsettings_form.phpnu[PK9B\J9" " .eHreport/grading/tests/privacy/provider_test.phpnu[PK9B\5555*8sHreport/grading/tests/behat/grading.featurenu[PK9B\syy ǨHreport/statistics/db/install.phpnu[PK9B\%gHreport/statistics/db/hooks.phpnu[PK9B\KuHreport/statistics/db/access.phpnu[PK9B\)&qq Hreport/statistics/db/upgrade.phpnu[PK9B\ zHreport/statistics/db/install.xmlnu[PK9B\ZZ-Hreport/statistics/lang/en/quiz_statistics.phpnu[PK9B\aw w Hreport/statistics/lib.phpnu[PK9B\w.NHreport/statistics/version.phpnu[PK9B\P#Hreport/statistics/statisticslib.phpnu[PK9B\un. Ireport/statistics/classes/privacy/provider.phpnu[PK9B\GH H ,$Ireport/statistics/classes/hook_callbacks.phpnu[PK9B\wk@"@"(Ireport/statistics/classes/calculated.phpnu[PK9B\R>`=Ireport/statistics/classes/event/observer/attempt_submitted.phpnu[PK9B\ <.EIreport/statistics/classes/task/recalculate.phpnu[PK9B\0Ϫyy5VIreport/statistics/classes/tests/statistics_helper.phpnu[PK9B\Ǎ9^Ireport/statistics/classes/tests/statistics_test_trait.phpnu[PK9B\"3?2 hIreport/statistics/classes/quiz_attempt_deleted.phpnu[PK9B\MT00(oIreport/statistics/classes/calculator.phpnu[PK9B\Á/rIreport/statistics/statistics_question_table.phpnu[PK9B\~tt[Ireport/statistics/upgrade.txtnu[PK9B\ehееIreport/statistics/report.phpnu[PK9B\9G Y Y&8tJreport/statistics/statistics_table.phpnu[PK9B\ly %Jreport/statistics/statistics_form.phpnu[PK9B\1dd0Jreport/statistics/tests/fixtures/questions02.csvnu[PK9B\KG0Jreport/statistics/tests/fixtures/questions01.csvnu[PK9B\ f %%8}Jreport/statistics/tests/fixtures/mdl_question_states.csvnu[PK9B\ -Kreport/statistics/tests/fixtures/qstats00.csvnu[PK9B\).Kreport/statistics/tests/fixtures/results00.csvnu[PK9B\V M)dd, Kreport/statistics/tests/fixtures/quizzes.csvnu[PK9B\h g  0 Kreport/statistics/tests/fixtures/questions00.csvnu[PK9B\, Kreport/statistics/tests/fixtures/steps01.csvnu[PK9B\{2Hl l 5M Kreport/statistics/tests/fixtures/responsecounts01.csvnu[PK9B\