2 // This file is part of Moodle - http://moodle.org/
4 // Moodle is free software: you can redistribute it and/or modify
5 // it under the terms of the GNU General Public License as published by
6 // the Free Software Foundation, either version 3 of the License, or
7 // (at your option) any later version.
9 // Moodle is distributed in the hope that it will be useful,
10 // but WITHOUT ANY WARRANTY; without even the implied warranty of
11 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 // GNU General Public License for more details.
14 // You should have received a copy of the GNU General Public License
15 // along with Moodle. If not, see <http://www.gnu.org/licenses/>.
18 * Library of functions used by the quiz module.
20 * This contains functions that are called from within the quiz module only
21 * Functions that are also called by core Moodle are in {@link lib.php}
22 * This script also loads the code in {@link questionlib.php} which holds
23 * the module-indpendent code for handling questions and which in turn
24 * initialises all the questiontype classes.
27 * @copyright 1999 onwards Martin Dougiamas and others {@link http://moodle.com}
28 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
32 defined('MOODLE_INTERNAL') ||
die();
34 require_once($CFG->dirroot
. '/mod/quiz/lib.php');
35 require_once($CFG->dirroot
. '/mod/quiz/accessmanager.php');
36 require_once($CFG->dirroot
. '/mod/quiz/accessmanager_form.php');
37 require_once($CFG->dirroot
. '/mod/quiz/renderer.php');
38 require_once($CFG->dirroot
. '/mod/quiz/attemptlib.php');
39 require_once($CFG->libdir
. '/completionlib.php');
40 require_once($CFG->libdir
. '/eventslib.php');
41 require_once($CFG->libdir
. '/filelib.php');
42 require_once($CFG->libdir
. '/questionlib.php');
46 * @var int We show the countdown timer if there is less than this amount of time left before the
47 * the quiz close date. (1 hour)
49 define('QUIZ_SHOW_TIME_BEFORE_DEADLINE', '3600');
52 * @var int If there are fewer than this many seconds left when the student submits
53 * a page of the quiz, then do not take them to the next page of the quiz. Instead
54 * close the quiz immediately.
56 define('QUIZ_MIN_TIME_TO_CONTINUE', '2');
59 * @var int We show no image when user selects No image from dropdown menu in quiz settings.
61 define('QUIZ_SHOWIMAGE_NONE', 0);
64 * @var int We show small image when user selects small image from dropdown menu in quiz settings.
66 define('QUIZ_SHOWIMAGE_SMALL', 1);
69 * @var int We show Large image when user selects Large image from dropdown menu in quiz settings.
71 define('QUIZ_SHOWIMAGE_LARGE', 2);
74 // Functions related to attempts ///////////////////////////////////////////////
77 * Creates an object to represent a new attempt at a quiz
79 * Creates an attempt object to represent an attempt at the quiz by the current
80 * user starting at the current time. The ->id field is not set. The object is
81 * NOT written to the database.
83 * @param object $quizobj the quiz object to create an attempt for.
84 * @param int $attemptnumber the sequence number for the attempt.
85 * @param object $lastattempt the previous attempt by this user, if any. Only needed
86 * if $attemptnumber > 1 and $quiz->attemptonlast is true.
87 * @param int $timenow the time the attempt was started at.
88 * @param bool $ispreview whether this new attempt is a preview.
89 * @param int $userid the id of the user attempting this quiz.
91 * @return object the newly created attempt object.
93 function quiz_create_attempt(quiz
$quizobj, $attemptnumber, $lastattempt, $timenow, $ispreview = false, $userid = null) {
96 if ($userid === null) {
100 $quiz = $quizobj->get_quiz();
101 if ($quiz->sumgrades
< 0.000005 && $quiz->grade
> 0.000005) {
102 throw new moodle_exception('cannotstartgradesmismatch', 'quiz',
103 new moodle_url('/mod/quiz/view.php', array('q' => $quiz->id
)),
104 array('grade' => quiz_format_grade($quiz, $quiz->grade
)));
107 if ($attemptnumber == 1 ||
!$quiz->attemptonlast
) {
108 // We are not building on last attempt so create a new attempt.
109 $attempt = new stdClass();
110 $attempt->quiz
= $quiz->id
;
111 $attempt->userid
= $userid;
112 $attempt->preview
= 0;
113 $attempt->layout
= '';
115 // Build on last attempt.
116 if (empty($lastattempt)) {
117 print_error('cannotfindprevattempt', 'quiz');
119 $attempt = $lastattempt;
122 $attempt->attempt
= $attemptnumber;
123 $attempt->timestart
= $timenow;
124 $attempt->timefinish
= 0;
125 $attempt->timemodified
= $timenow;
126 $attempt->timemodifiedoffline
= 0;
127 $attempt->state
= quiz_attempt
::IN_PROGRESS
;
128 $attempt->currentpage
= 0;
129 $attempt->sumgrades
= null;
131 // If this is a preview, mark it as such.
133 $attempt->preview
= 1;
136 $timeclose = $quizobj->get_access_manager($timenow)->get_end_time($attempt);
137 if ($timeclose === false ||
$ispreview) {
138 $attempt->timecheckstate
= null;
140 $attempt->timecheckstate
= $timeclose;
146 * Start a normal, new, quiz attempt.
148 * @param quiz $quizobj the quiz object to start an attempt for.
149 * @param question_usage_by_activity $quba
150 * @param object $attempt
151 * @param integer $attemptnumber starting from 1
152 * @param integer $timenow the attempt start time
153 * @param array $questionids slot number => question id. Used for random questions, to force the choice
154 * of a particular actual question. Intended for testing purposes only.
155 * @param array $forcedvariantsbyslot slot number => variant. Used for questions with variants,
156 * to force the choice of a particular variant. Intended for testing
158 * @throws moodle_exception
159 * @return object modified attempt object
161 function quiz_start_new_attempt($quizobj, $quba, $attempt, $attemptnumber, $timenow,
162 $questionids = array(), $forcedvariantsbyslot = array()) {
164 // Usages for this user's previous quiz attempts.
165 $qubaids = new \mod_quiz\question\
qubaids_for_users_attempts(
166 $quizobj->get_quizid(), $attempt->userid
);
168 // Fully load all the questions in this quiz.
169 $quizobj->preload_questions();
170 $quizobj->load_questions();
172 // First load all the non-random questions.
173 $randomfound = false;
175 $questions = array();
178 foreach ($quizobj->get_questions() as $questiondata) {
180 $maxmark[$slot] = $questiondata->maxmark
;
181 $page[$slot] = $questiondata->page
;
182 if ($questiondata->qtype
== 'random') {
186 if (!$quizobj->get_quiz()->shuffleanswers
) {
187 $questiondata->options
->shuffleanswers
= false;
189 $questions[$slot] = question_bank
::make_question($questiondata);
192 // Then find a question to go in place of each random question.
195 $usedquestionids = array();
196 foreach ($questions as $question) {
197 if (isset($usedquestions[$question->id
])) {
198 $usedquestionids[$question->id
] +
= 1;
200 $usedquestionids[$question->id
] = 1;
203 $randomloader = new \core_question\bank\random_question_loader
($qubaids, $usedquestionids);
205 foreach ($quizobj->get_questions() as $questiondata) {
207 if ($questiondata->qtype
!= 'random') {
211 $tagids = quiz_retrieve_slot_tag_ids($questiondata->slotid
);
213 // Deal with fixed random choices for testing.
214 if (isset($questionids[$quba->next_slot_number()])) {
215 if ($randomloader->is_question_available($questiondata->category
,
216 (bool) $questiondata->questiontext
, $questionids[$quba->next_slot_number()], $tagids)) {
217 $questions[$slot] = question_bank
::load_question(
218 $questionids[$quba->next_slot_number()], $quizobj->get_quiz()->shuffleanswers
);
221 throw new coding_exception('Forced question id not available.');
225 // Normal case, pick one at random.
226 $questionid = $randomloader->get_next_question_id($questiondata->randomfromcategory
,
227 $questiondata->randomincludingsubcategories
, $tagids);
228 if ($questionid === null) {
229 throw new moodle_exception('notenoughrandomquestions', 'quiz',
230 $quizobj->view_url(), $questiondata);
233 $questions[$slot] = question_bank
::load_question($questionid,
234 $quizobj->get_quiz()->shuffleanswers
);
238 // Finally add them all to the usage.
240 foreach ($questions as $slot => $question) {
241 $newslot = $quba->add_question($question, $maxmark[$slot]);
242 if ($newslot != $slot) {
243 throw new coding_exception('Slot numbers have got confused.');
247 // Start all the questions.
248 $variantstrategy = new core_question\engine\variants\
least_used_strategy($quba, $qubaids);
250 if (!empty($forcedvariantsbyslot)) {
251 $forcedvariantsbyseed = question_variant_forced_choices_selection_strategy
::prepare_forced_choices_array(
252 $forcedvariantsbyslot, $quba);
253 $variantstrategy = new question_variant_forced_choices_selection_strategy(
254 $forcedvariantsbyseed, $variantstrategy);
257 $quba->start_all_questions($variantstrategy, $timenow);
259 // Work out the attempt layout.
260 $sections = $quizobj->get_sections();
261 foreach ($sections as $i => $section) {
262 if (isset($sections[$i +
1])) {
263 $sections[$i]->lastslot
= $sections[$i +
1]->firstslot
- 1;
265 $sections[$i]->lastslot
= count($questions);
270 foreach ($sections as $section) {
271 if ($section->shufflequestions
) {
272 $questionsinthissection = array();
273 for ($slot = $section->firstslot
; $slot <= $section->lastslot
; $slot +
= 1) {
274 $questionsinthissection[] = $slot;
276 shuffle($questionsinthissection);
277 $questionsonthispage = 0;
278 foreach ($questionsinthissection as $slot) {
279 if ($questionsonthispage && $questionsonthispage == $quizobj->get_quiz()->questionsperpage
) {
281 $questionsonthispage = 0;
284 $questionsonthispage +
= 1;
288 $currentpage = $page[$section->firstslot
];
289 for ($slot = $section->firstslot
; $slot <= $section->lastslot
; $slot +
= 1) {
290 if ($currentpage !== null && $page[$slot] != $currentpage) {
294 $currentpage = $page[$slot];
298 // Each section ends with a page break.
301 $attempt->layout
= implode(',', $layout);
307 * Start a subsequent new attempt, in each attempt builds on last mode.
309 * @param question_usage_by_activity $quba this question usage
310 * @param object $attempt this attempt
311 * @param object $lastattempt last attempt
312 * @return object modified attempt object
315 function quiz_start_attempt_built_on_last($quba, $attempt, $lastattempt) {
316 $oldquba = question_engine
::load_questions_usage_by_activity($lastattempt->uniqueid
);
318 $oldnumberstonew = array();
319 foreach ($oldquba->get_attempt_iterator() as $oldslot => $oldqa) {
320 $newslot = $quba->add_question($oldqa->get_question(), $oldqa->get_max_mark());
322 $quba->start_question_based_on($newslot, $oldqa);
324 $oldnumberstonew[$oldslot] = $newslot;
327 // Update attempt layout.
328 $newlayout = array();
329 foreach (explode(',', $lastattempt->layout
) as $oldslot) {
331 $newlayout[] = $oldnumberstonew[$oldslot];
336 $attempt->layout
= implode(',', $newlayout);
341 * The save started question usage and quiz attempt in db and log the started attempt.
343 * @param quiz $quizobj
344 * @param question_usage_by_activity $quba
345 * @param object $attempt
346 * @return object attempt object with uniqueid and id set.
348 function quiz_attempt_save_started($quizobj, $quba, $attempt) {
350 // Save the attempt in the database.
351 question_engine
::save_questions_usage_by_activity($quba);
352 $attempt->uniqueid
= $quba->get_id();
353 $attempt->id
= $DB->insert_record('quiz_attempts', $attempt);
355 // Params used by the events below.
357 'objectid' => $attempt->id
,
358 'relateduserid' => $attempt->userid
,
359 'courseid' => $quizobj->get_courseid(),
360 'context' => $quizobj->get_context()
362 // Decide which event we are using.
363 if ($attempt->preview
) {
364 $params['other'] = array(
365 'quizid' => $quizobj->get_quizid()
367 $event = \mod_quiz\event\attempt_preview_started
::create($params);
369 $event = \mod_quiz\event\attempt_started
::create($params);
373 // Trigger the event.
374 $event->add_record_snapshot('quiz', $quizobj->get_quiz());
375 $event->add_record_snapshot('quiz_attempts', $attempt);
382 * Returns an unfinished attempt (if there is one) for the given
383 * user on the given quiz. This function does not return preview attempts.
385 * @param int $quizid the id of the quiz.
386 * @param int $userid the id of the user.
388 * @return mixed the unfinished attempt if there is one, false if not.
390 function quiz_get_user_attempt_unfinished($quizid, $userid) {
391 $attempts = quiz_get_user_attempts($quizid, $userid, 'unfinished', true);
393 return array_shift($attempts);
400 * Delete a quiz attempt.
401 * @param mixed $attempt an integer attempt id or an attempt object
402 * (row of the quiz_attempts table).
403 * @param object $quiz the quiz object.
405 function quiz_delete_attempt($attempt, $quiz) {
407 if (is_numeric($attempt)) {
408 if (!$attempt = $DB->get_record('quiz_attempts', array('id' => $attempt))) {
413 if ($attempt->quiz
!= $quiz->id
) {
414 debugging("Trying to delete attempt $attempt->id which belongs to quiz $attempt->quiz " .
415 "but was passed quiz $quiz->id.");
419 if (!isset($quiz->cmid
)) {
420 $cm = get_coursemodule_from_instance('quiz', $quiz->id
, $quiz->course
);
421 $quiz->cmid
= $cm->id
;
424 question_engine
::delete_questions_usage_by_activity($attempt->uniqueid
);
425 $DB->delete_records('quiz_attempts', array('id' => $attempt->id
));
427 // Log the deletion of the attempt if not a preview.
428 if (!$attempt->preview
) {
430 'objectid' => $attempt->id
,
431 'relateduserid' => $attempt->userid
,
432 'context' => context_module
::instance($quiz->cmid
),
434 'quizid' => $quiz->id
437 $event = \mod_quiz\event\attempt_deleted
::create($params);
438 $event->add_record_snapshot('quiz_attempts', $attempt);
442 // Search quiz_attempts for other instances by this user.
443 // If none, then delete record for this quiz, this user from quiz_grades
444 // else recalculate best grade.
445 $userid = $attempt->userid
;
446 if (!$DB->record_exists('quiz_attempts', array('userid' => $userid, 'quiz' => $quiz->id
))) {
447 $DB->delete_records('quiz_grades', array('userid' => $userid, 'quiz' => $quiz->id
));
449 quiz_save_best_grade($quiz, $userid);
452 quiz_update_grades($quiz, $userid);
456 * Delete all the preview attempts at a quiz, or possibly all the attempts belonging
458 * @param object $quiz the quiz object.
459 * @param int $userid (optional) if given, only delete the previews belonging to this user.
461 function quiz_delete_previews($quiz, $userid = null) {
463 $conditions = array('quiz' => $quiz->id
, 'preview' => 1);
464 if (!empty($userid)) {
465 $conditions['userid'] = $userid;
467 $previewattempts = $DB->get_records('quiz_attempts', $conditions);
468 foreach ($previewattempts as $attempt) {
469 quiz_delete_attempt($attempt, $quiz);
474 * @param int $quizid The quiz id.
475 * @return bool whether this quiz has any (non-preview) attempts.
477 function quiz_has_attempts($quizid) {
479 return $DB->record_exists('quiz_attempts', array('quiz' => $quizid, 'preview' => 0));
482 // Functions to do with quiz layout and pages //////////////////////////////////
485 * Repaginate the questions in a quiz
486 * @param int $quizid the id of the quiz to repaginate.
487 * @param int $slotsperpage number of items to put on each page. 0 means unlimited.
489 function quiz_repaginate_questions($quizid, $slotsperpage) {
491 $trans = $DB->start_delegated_transaction();
493 $sections = $DB->get_records('quiz_sections', array('quizid' => $quizid), 'firstslot ASC');
494 $firstslots = array();
495 foreach ($sections as $section) {
496 if ((int)$section->firstslot
=== 1) {
499 $firstslots[] = $section->firstslot
;
502 $slots = $DB->get_records('quiz_slots', array('quizid' => $quizid),
505 $slotsonthispage = 0;
506 foreach ($slots as $slot) {
507 if (($firstslots && in_array($slot->slot
, $firstslots)) ||
508 ($slotsonthispage && $slotsonthispage == $slotsperpage)) {
510 $slotsonthispage = 0;
512 if ($slot->page
!= $currentpage) {
513 $DB->set_field('quiz_slots', 'page', $currentpage, array('id' => $slot->id
));
515 $slotsonthispage +
= 1;
518 $trans->allow_commit();
521 // Functions to do with quiz grades ////////////////////////////////////////////
524 * Convert the raw grade stored in $attempt into a grade out of the maximum
525 * grade for this quiz.
527 * @param float $rawgrade the unadjusted grade, fof example $attempt->sumgrades
528 * @param object $quiz the quiz object. Only the fields grade, sumgrades and decimalpoints are used.
529 * @param bool|string $format whether to format the results for display
530 * or 'question' to format a question grade (different number of decimal places.
531 * @return float|string the rescaled grade, or null/the lang string 'notyetgraded'
532 * if the $grade is null.
534 function quiz_rescale_grade($rawgrade, $quiz, $format = true) {
535 if (is_null($rawgrade)) {
537 } else if ($quiz->sumgrades
>= 0.000005) {
538 $grade = $rawgrade * $quiz->grade
/ $quiz->sumgrades
;
542 if ($format === 'question') {
543 $grade = quiz_format_question_grade($quiz, $grade);
544 } else if ($format) {
545 $grade = quiz_format_grade($quiz, $grade);
551 * Get the feedback object for this grade on this quiz.
553 * @param float $grade a grade on this quiz.
554 * @param object $quiz the quiz settings.
555 * @return false|stdClass the record object or false if there is not feedback for the given grade
558 function quiz_feedback_record_for_grade($grade, $quiz) {
561 // With CBM etc, it is possible to get -ve grades, which would then not match
562 // any feedback. Therefore, we replace -ve grades with 0.
563 $grade = max($grade, 0);
565 $feedback = $DB->get_record_select('quiz_feedback',
566 'quizid = ? AND mingrade <= ? AND ? < maxgrade', array($quiz->id
, $grade, $grade));
572 * Get the feedback text that should be show to a student who
573 * got this grade on this quiz. The feedback is processed ready for diplay.
575 * @param float $grade a grade on this quiz.
576 * @param object $quiz the quiz settings.
577 * @param object $context the quiz context.
578 * @return string the comment that corresponds to this grade (empty string if there is not one.
580 function quiz_feedback_for_grade($grade, $quiz, $context) {
582 if (is_null($grade)) {
586 $feedback = quiz_feedback_record_for_grade($grade, $quiz);
588 if (empty($feedback->feedbacktext
)) {
592 // Clean the text, ready for display.
593 $formatoptions = new stdClass();
594 $formatoptions->noclean
= true;
595 $feedbacktext = file_rewrite_pluginfile_urls($feedback->feedbacktext
, 'pluginfile.php',
596 $context->id
, 'mod_quiz', 'feedback', $feedback->id
);
597 $feedbacktext = format_text($feedbacktext, $feedback->feedbacktextformat
, $formatoptions);
599 return $feedbacktext;
603 * @param object $quiz the quiz database row.
604 * @return bool Whether this quiz has any non-blank feedback text.
606 function quiz_has_feedback($quiz) {
608 static $cache = array();
609 if (!array_key_exists($quiz->id
, $cache)) {
610 $cache[$quiz->id
] = quiz_has_grades($quiz) &&
611 $DB->record_exists_select('quiz_feedback', "quizid = ? AND " .
612 $DB->sql_isnotempty('quiz_feedback', 'feedbacktext', false, true),
615 return $cache[$quiz->id
];
619 * Update the sumgrades field of the quiz. This needs to be called whenever
620 * the grading structure of the quiz is changed. For example if a question is
621 * added or removed, or a question weight is changed.
623 * You should call {@link quiz_delete_previews()} before you call this function.
625 * @param object $quiz a quiz.
627 function quiz_update_sumgrades($quiz) {
630 $sql = 'UPDATE {quiz}
631 SET sumgrades = COALESCE((
634 WHERE quizid = {quiz}.id
637 $DB->execute($sql, array($quiz->id
));
638 $quiz->sumgrades
= $DB->get_field('quiz', 'sumgrades', array('id' => $quiz->id
));
640 if ($quiz->sumgrades
< 0.000005 && quiz_has_attempts($quiz->id
)) {
641 // If the quiz has been attempted, and the sumgrades has been
642 // set to 0, then we must also set the maximum possible grade to 0, or
643 // we will get a divide by zero error.
644 quiz_set_grade(0, $quiz);
649 * Update the sumgrades field of the attempts at a quiz.
651 * @param object $quiz a quiz.
653 function quiz_update_all_attempt_sumgrades($quiz) {
655 $dm = new question_engine_data_mapper();
658 $sql = "UPDATE {quiz_attempts}
660 timemodified = :timenow,
662 {$dm->sum_usage_marks_subquery('uniqueid')}
664 WHERE quiz = :quizid AND state = :finishedstate";
665 $DB->execute($sql, array('timenow' => $timenow, 'quizid' => $quiz->id
,
666 'finishedstate' => quiz_attempt
::FINISHED
));
670 * The quiz grade is the maximum that student's results are marked out of. When it
671 * changes, the corresponding data in quiz_grades and quiz_feedback needs to be
672 * rescaled. After calling this function, you probably need to call
673 * quiz_update_all_attempt_sumgrades, quiz_update_all_final_grades and
674 * quiz_update_grades.
676 * @param float $newgrade the new maximum grade for the quiz.
677 * @param object $quiz the quiz we are updating. Passed by reference so its
678 * grade field can be updated too.
679 * @return bool indicating success or failure.
681 function quiz_set_grade($newgrade, $quiz) {
683 // This is potentially expensive, so only do it if necessary.
684 if (abs($quiz->grade
- $newgrade) < 1e-7) {
689 $oldgrade = $quiz->grade
;
690 $quiz->grade
= $newgrade;
692 // Use a transaction, so that on those databases that support it, this is safer.
693 $transaction = $DB->start_delegated_transaction();
695 // Update the quiz table.
696 $DB->set_field('quiz', 'grade', $newgrade, array('id' => $quiz->instance
));
699 // If the old grade was zero, we cannot rescale, we have to recompute.
700 // We also recompute if the old grade was too small to avoid underflow problems.
701 quiz_update_all_final_grades($quiz);
704 // We can rescale the grades efficiently.
705 $timemodified = time();
708 SET grade = ? * grade, timemodified = ?
710 ", array($newgrade/$oldgrade, $timemodified, $quiz->id
));
713 if ($oldgrade > 1e-7) {
714 // Update the quiz_feedback table.
715 $factor = $newgrade/$oldgrade;
717 UPDATE {quiz_feedback}
718 SET mingrade = ? * mingrade, maxgrade = ? * maxgrade
720 ", array($factor, $factor, $quiz->id
));
723 // Update grade item and send all grades to gradebook.
724 quiz_grade_item_update($quiz);
725 quiz_update_grades($quiz);
727 $transaction->allow_commit();
732 * Save the overall grade for a user at a quiz in the quiz_grades table
734 * @param object $quiz The quiz for which the best grade is to be calculated and then saved.
735 * @param int $userid The userid to calculate the grade for. Defaults to the current user.
736 * @param array $attempts The attempts of this user. Useful if you are
737 * looping through many users. Attempts can be fetched in one master query to
738 * avoid repeated querying.
739 * @return bool Indicates success or failure.
741 function quiz_save_best_grade($quiz, $userid = null, $attempts = array()) {
742 global $DB, $OUTPUT, $USER;
744 if (empty($userid)) {
749 // Get all the attempts made by the user.
750 $attempts = quiz_get_user_attempts($quiz->id
, $userid);
753 // Calculate the best grade.
754 $bestgrade = quiz_calculate_best_grade($quiz, $attempts);
755 $bestgrade = quiz_rescale_grade($bestgrade, $quiz, false);
757 // Save the best grade in the database.
758 if (is_null($bestgrade)) {
759 $DB->delete_records('quiz_grades', array('quiz' => $quiz->id
, 'userid' => $userid));
761 } else if ($grade = $DB->get_record('quiz_grades',
762 array('quiz' => $quiz->id
, 'userid' => $userid))) {
763 $grade->grade
= $bestgrade;
764 $grade->timemodified
= time();
765 $DB->update_record('quiz_grades', $grade);
768 $grade = new stdClass();
769 $grade->quiz
= $quiz->id
;
770 $grade->userid
= $userid;
771 $grade->grade
= $bestgrade;
772 $grade->timemodified
= time();
773 $DB->insert_record('quiz_grades', $grade);
776 quiz_update_grades($quiz, $userid);
780 * Calculate the overall grade for a quiz given a number of attempts by a particular user.
782 * @param object $quiz the quiz settings object.
783 * @param array $attempts an array of all the user's attempts at this quiz in order.
784 * @return float the overall grade
786 function quiz_calculate_best_grade($quiz, $attempts) {
788 switch ($quiz->grademethod
) {
790 case QUIZ_ATTEMPTFIRST
:
791 $firstattempt = reset($attempts);
792 return $firstattempt->sumgrades
;
794 case QUIZ_ATTEMPTLAST
:
795 $lastattempt = end($attempts);
796 return $lastattempt->sumgrades
;
798 case QUIZ_GRADEAVERAGE
:
801 foreach ($attempts as $attempt) {
802 if (!is_null($attempt->sumgrades
)) {
803 $sum +
= $attempt->sumgrades
;
810 return $sum / $count;
812 case QUIZ_GRADEHIGHEST
:
815 foreach ($attempts as $attempt) {
816 if ($attempt->sumgrades
> $max) {
817 $max = $attempt->sumgrades
;
825 * Update the final grade at this quiz for all students.
827 * This function is equivalent to calling quiz_save_best_grade for all
828 * users, but much more efficient.
830 * @param object $quiz the quiz settings.
832 function quiz_update_all_final_grades($quiz) {
835 if (!$quiz->sumgrades
) {
839 $param = array('iquizid' => $quiz->id
, 'istatefinished' => quiz_attempt
::FINISHED
);
840 $firstlastattemptjoin = "JOIN (
843 MIN(attempt) AS firstattempt,
844 MAX(attempt) AS lastattempt
846 FROM {quiz_attempts} iquiza
849 iquiza.state = :istatefinished AND
850 iquiza.preview = 0 AND
851 iquiza.quiz = :iquizid
853 GROUP BY iquiza.userid
854 ) first_last_attempts ON first_last_attempts.userid = quiza.userid";
856 switch ($quiz->grademethod
) {
857 case QUIZ_ATTEMPTFIRST
:
858 // Because of the where clause, there will only be one row, but we
859 // must still use an aggregate function.
860 $select = 'MAX(quiza.sumgrades)';
861 $join = $firstlastattemptjoin;
862 $where = 'quiza.attempt = first_last_attempts.firstattempt AND';
865 case QUIZ_ATTEMPTLAST
:
866 // Because of the where clause, there will only be one row, but we
867 // must still use an aggregate function.
868 $select = 'MAX(quiza.sumgrades)';
869 $join = $firstlastattemptjoin;
870 $where = 'quiza.attempt = first_last_attempts.lastattempt AND';
873 case QUIZ_GRADEAVERAGE
:
874 $select = 'AVG(quiza.sumgrades)';
880 case QUIZ_GRADEHIGHEST
:
881 $select = 'MAX(quiza.sumgrades)';
887 if ($quiz->sumgrades
>= 0.000005) {
888 $finalgrade = $select . ' * ' . ($quiz->grade
/ $quiz->sumgrades
);
892 $param['quizid'] = $quiz->id
;
893 $param['quizid2'] = $quiz->id
;
894 $param['quizid3'] = $quiz->id
;
895 $param['quizid4'] = $quiz->id
;
896 $param['statefinished'] = quiz_attempt
::FINISHED
;
897 $param['statefinished2'] = quiz_attempt
::FINISHED
;
898 $finalgradesubquery = "
899 SELECT quiza.userid, $finalgrade AS newgrade
900 FROM {quiz_attempts} quiza
904 quiza.state = :statefinished AND
905 quiza.preview = 0 AND
906 quiza.quiz = :quizid3
907 GROUP BY quiza.userid";
909 $changedgrades = $DB->get_records_sql("
910 SELECT users.userid, qg.id, qg.grade, newgrades.newgrade
914 FROM {quiz_grades} qg
917 SELECT DISTINCT userid
918 FROM {quiz_attempts} quiza2
920 quiza2.state = :statefinished2 AND
921 quiza2.preview = 0 AND
922 quiza2.quiz = :quizid2
925 LEFT JOIN {quiz_grades} qg ON qg.userid = users.userid AND qg.quiz = :quizid4
929 ) newgrades ON newgrades.userid = users.userid
932 ABS(newgrades.newgrade - qg.grade) > 0.000005 OR
933 ((newgrades.newgrade IS NULL OR qg.grade IS NULL) AND NOT
934 (newgrades.newgrade IS NULL AND qg.grade IS NULL))",
935 // The mess on the previous line is detecting where the value is
936 // NULL in one column, and NOT NULL in the other, but SQL does
937 // not have an XOR operator, and MS SQL server can't cope with
938 // (newgrades.newgrade IS NULL) <> (qg.grade IS NULL).
943 foreach ($changedgrades as $changedgrade) {
945 if (is_null($changedgrade->newgrade
)) {
946 $todelete[] = $changedgrade->userid
;
948 } else if (is_null($changedgrade->grade
)) {
949 $toinsert = new stdClass();
950 $toinsert->quiz
= $quiz->id
;
951 $toinsert->userid
= $changedgrade->userid
;
952 $toinsert->timemodified
= $timenow;
953 $toinsert->grade
= $changedgrade->newgrade
;
954 $DB->insert_record('quiz_grades', $toinsert);
957 $toupdate = new stdClass();
958 $toupdate->id
= $changedgrade->id
;
959 $toupdate->grade
= $changedgrade->newgrade
;
960 $toupdate->timemodified
= $timenow;
961 $DB->update_record('quiz_grades', $toupdate);
965 if (!empty($todelete)) {
966 list($test, $params) = $DB->get_in_or_equal($todelete);
967 $DB->delete_records_select('quiz_grades', 'quiz = ? AND userid ' . $test,
968 array_merge(array($quiz->id
), $params));
973 * Efficiently update check state time on all open attempts
975 * @param array $conditions optional restrictions on which attempts to update
976 * Allowed conditions:
977 * courseid => (array|int) attempts in given course(s)
978 * userid => (array|int) attempts for given user(s)
979 * quizid => (array|int) attempts in given quiz(s)
980 * groupid => (array|int) quizzes with some override for given group(s)
983 function quiz_update_open_attempts(array $conditions) {
986 foreach ($conditions as &$value) {
987 if (!is_array($value)) {
988 $value = array($value);
993 $wheres = array("quiza.state IN ('inprogress', 'overdue')");
994 $iwheres = array("iquiza.state IN ('inprogress', 'overdue')");
996 if (isset($conditions['courseid'])) {
997 list ($incond, $inparams) = $DB->get_in_or_equal($conditions['courseid'], SQL_PARAMS_NAMED
, 'cid');
998 $params = array_merge($params, $inparams);
999 $wheres[] = "quiza.quiz IN (SELECT q.id FROM {quiz} q WHERE q.course $incond)";
1000 list ($incond, $inparams) = $DB->get_in_or_equal($conditions['courseid'], SQL_PARAMS_NAMED
, 'icid');
1001 $params = array_merge($params, $inparams);
1002 $iwheres[] = "iquiza.quiz IN (SELECT q.id FROM {quiz} q WHERE q.course $incond)";
1005 if (isset($conditions['userid'])) {
1006 list ($incond, $inparams) = $DB->get_in_or_equal($conditions['userid'], SQL_PARAMS_NAMED
, 'uid');
1007 $params = array_merge($params, $inparams);
1008 $wheres[] = "quiza.userid $incond";
1009 list ($incond, $inparams) = $DB->get_in_or_equal($conditions['userid'], SQL_PARAMS_NAMED
, 'iuid');
1010 $params = array_merge($params, $inparams);
1011 $iwheres[] = "iquiza.userid $incond";
1014 if (isset($conditions['quizid'])) {
1015 list ($incond, $inparams) = $DB->get_in_or_equal($conditions['quizid'], SQL_PARAMS_NAMED
, 'qid');
1016 $params = array_merge($params, $inparams);
1017 $wheres[] = "quiza.quiz $incond";
1018 list ($incond, $inparams) = $DB->get_in_or_equal($conditions['quizid'], SQL_PARAMS_NAMED
, 'iqid');
1019 $params = array_merge($params, $inparams);
1020 $iwheres[] = "iquiza.quiz $incond";
1023 if (isset($conditions['groupid'])) {
1024 list ($incond, $inparams) = $DB->get_in_or_equal($conditions['groupid'], SQL_PARAMS_NAMED
, 'gid');
1025 $params = array_merge($params, $inparams);
1026 $wheres[] = "quiza.quiz IN (SELECT qo.quiz FROM {quiz_overrides} qo WHERE qo.groupid $incond)";
1027 list ($incond, $inparams) = $DB->get_in_or_equal($conditions['groupid'], SQL_PARAMS_NAMED
, 'igid');
1028 $params = array_merge($params, $inparams);
1029 $iwheres[] = "iquiza.quiz IN (SELECT qo.quiz FROM {quiz_overrides} qo WHERE qo.groupid $incond)";
1032 // SQL to compute timeclose and timelimit for each attempt:
1033 $quizausersql = quiz_get_attempt_usertime_sql(
1034 implode("\n AND ", $iwheres));
1036 // SQL to compute the new timecheckstate
1037 $timecheckstatesql = "
1038 CASE WHEN quizauser.usertimelimit = 0 AND quizauser.usertimeclose = 0 THEN NULL
1039 WHEN quizauser.usertimelimit = 0 THEN quizauser.usertimeclose
1040 WHEN quizauser.usertimeclose = 0 THEN quiza.timestart + quizauser.usertimelimit
1041 WHEN quiza.timestart + quizauser.usertimelimit < quizauser.usertimeclose THEN quiza.timestart + quizauser.usertimelimit
1042 ELSE quizauser.usertimeclose END +
1043 CASE WHEN quiza.state = 'overdue' THEN quiz.graceperiod ELSE 0 END";
1045 // SQL to select which attempts to process
1046 $attemptselect = implode("\n AND ", $wheres);
1049 * Each database handles updates with inner joins differently:
1050 * - mysql does not allow a FROM clause
1051 * - postgres and mssql allow FROM but handle table aliases differently
1052 * - oracle requires a subquery
1054 * Different code for each database.
1057 $dbfamily = $DB->get_dbfamily();
1058 if ($dbfamily == 'mysql') {
1059 $updatesql = "UPDATE {quiz_attempts} quiza
1060 JOIN {quiz} quiz ON quiz.id = quiza.quiz
1061 JOIN ( $quizausersql ) quizauser ON quizauser.id = quiza.id
1062 SET quiza.timecheckstate = $timecheckstatesql
1063 WHERE $attemptselect";
1064 } else if ($dbfamily == 'postgres') {
1065 $updatesql = "UPDATE {quiz_attempts} quiza
1066 SET timecheckstate = $timecheckstatesql
1067 FROM {quiz} quiz, ( $quizausersql ) quizauser
1068 WHERE quiz.id = quiza.quiz
1069 AND quizauser.id = quiza.id
1070 AND $attemptselect";
1071 } else if ($dbfamily == 'mssql') {
1072 $updatesql = "UPDATE quiza
1073 SET timecheckstate = $timecheckstatesql
1074 FROM {quiz_attempts} quiza
1075 JOIN {quiz} quiz ON quiz.id = quiza.quiz
1076 JOIN ( $quizausersql ) quizauser ON quizauser.id = quiza.id
1077 WHERE $attemptselect";
1079 // oracle, sqlite and others
1080 $updatesql = "UPDATE {quiz_attempts} quiza
1081 SET timecheckstate = (
1082 SELECT $timecheckstatesql
1083 FROM {quiz} quiz, ( $quizausersql ) quizauser
1084 WHERE quiz.id = quiza.quiz
1085 AND quizauser.id = quiza.id
1087 WHERE $attemptselect";
1090 $DB->execute($updatesql, $params);
1094 * Returns SQL to compute timeclose and timelimit for every attempt, taking into account user and group overrides.
1095 * The query used herein is very similar to the one in function quiz_get_user_timeclose, so, in case you
1096 * would change either one of them, make sure to apply your changes to both.
1098 * @param string $redundantwhereclauses extra where clauses to add to the subquery
1099 * for performance. These can use the table alias iquiza for the quiz attempts table.
1100 * @return string SQL select with columns attempt.id, usertimeclose, usertimelimit.
1102 function quiz_get_attempt_usertime_sql($redundantwhereclauses = '') {
1103 if ($redundantwhereclauses) {
1104 $redundantwhereclauses = 'WHERE ' . $redundantwhereclauses;
1106 // The multiple qgo JOINS are necessary because we want timeclose/timelimit = 0 (unlimited) to supercede
1107 // any other group override
1110 COALESCE(MAX(quo.timeclose), MAX(qgo1.timeclose), MAX(qgo2.timeclose), iquiz.timeclose) AS usertimeclose,
1111 COALESCE(MAX(quo.timelimit), MAX(qgo3.timelimit), MAX(qgo4.timelimit), iquiz.timelimit) AS usertimelimit
1113 FROM {quiz_attempts} iquiza
1114 JOIN {quiz} iquiz ON iquiz.id = iquiza.quiz
1115 LEFT JOIN {quiz_overrides} quo ON quo.quiz = iquiza.quiz AND quo.userid = iquiza.userid
1116 LEFT JOIN {groups_members} gm ON gm.userid = iquiza.userid
1117 LEFT JOIN {quiz_overrides} qgo1 ON qgo1.quiz = iquiza.quiz AND qgo1.groupid = gm.groupid AND qgo1.timeclose = 0
1118 LEFT JOIN {quiz_overrides} qgo2 ON qgo2.quiz = iquiza.quiz AND qgo2.groupid = gm.groupid AND qgo2.timeclose > 0
1119 LEFT JOIN {quiz_overrides} qgo3 ON qgo3.quiz = iquiza.quiz AND qgo3.groupid = gm.groupid AND qgo3.timelimit = 0
1120 LEFT JOIN {quiz_overrides} qgo4 ON qgo4.quiz = iquiza.quiz AND qgo4.groupid = gm.groupid AND qgo4.timelimit > 0
1121 $redundantwhereclauses
1122 GROUP BY iquiza.id, iquiz.id, iquiz.timeclose, iquiz.timelimit";
1123 return $quizausersql;
1127 * Return the attempt with the best grade for a quiz
1129 * Which attempt is the best depends on $quiz->grademethod. If the grade
1130 * method is GRADEAVERAGE then this function simply returns the last attempt.
1131 * @return object The attempt with the best grade
1132 * @param object $quiz The quiz for which the best grade is to be calculated
1133 * @param array $attempts An array of all the attempts of the user at the quiz
1135 function quiz_calculate_best_attempt($quiz, $attempts) {
1137 switch ($quiz->grademethod
) {
1139 case QUIZ_ATTEMPTFIRST
:
1140 foreach ($attempts as $attempt) {
1145 case QUIZ_GRADEAVERAGE
: // We need to do something with it.
1146 case QUIZ_ATTEMPTLAST
:
1147 foreach ($attempts as $attempt) {
1153 case QUIZ_GRADEHIGHEST
:
1155 foreach ($attempts as $attempt) {
1156 if ($attempt->sumgrades
> $max) {
1157 $max = $attempt->sumgrades
;
1158 $maxattempt = $attempt;
1166 * @return array int => lang string the options for calculating the quiz grade
1167 * from the individual attempt grades.
1169 function quiz_get_grading_options() {
1171 QUIZ_GRADEHIGHEST
=> get_string('gradehighest', 'quiz'),
1172 QUIZ_GRADEAVERAGE
=> get_string('gradeaverage', 'quiz'),
1173 QUIZ_ATTEMPTFIRST
=> get_string('attemptfirst', 'quiz'),
1174 QUIZ_ATTEMPTLAST
=> get_string('attemptlast', 'quiz')
1179 * @param int $option one of the values QUIZ_GRADEHIGHEST, QUIZ_GRADEAVERAGE,
1180 * QUIZ_ATTEMPTFIRST or QUIZ_ATTEMPTLAST.
1181 * @return the lang string for that option.
1183 function quiz_get_grading_option_name($option) {
1184 $strings = quiz_get_grading_options();
1185 return $strings[$option];
1189 * @return array string => lang string the options for handling overdue quiz
1192 function quiz_get_overdue_handling_options() {
1194 'autosubmit' => get_string('overduehandlingautosubmit', 'quiz'),
1195 'graceperiod' => get_string('overduehandlinggraceperiod', 'quiz'),
1196 'autoabandon' => get_string('overduehandlingautoabandon', 'quiz'),
1201 * Get the choices for what size user picture to show.
1202 * @return array string => lang string the options for whether to display the user's picture.
1204 function quiz_get_user_image_options() {
1206 QUIZ_SHOWIMAGE_NONE
=> get_string('shownoimage', 'quiz'),
1207 QUIZ_SHOWIMAGE_SMALL
=> get_string('showsmallimage', 'quiz'),
1208 QUIZ_SHOWIMAGE_LARGE
=> get_string('showlargeimage', 'quiz'),
1213 * Return an user's timeclose for all quizzes in a course, hereby taking into account group and user overrides.
1214 * The query used herein is very similar to the one in function quiz_get_attempt_usertime_sql, so, in case you
1215 * would change either one of them, make sure to apply your changes to both.
1217 * @param int $courseid the course id.
1218 * @return object An object with quizids and unixdates of the most lenient close overrides, if any.
1220 function quiz_get_user_timeclose($courseid) {
1223 // For teacher and manager/admins return timeclose.
1224 if (has_capability('moodle/course:update', context_course
::instance($courseid))) {
1225 $sql = "SELECT quiz.id, quiz.timeclose AS usertimeclose, COALESCE(quiz.timelimit, 0) AS usertimelimit
1227 WHERE quiz.course = :courseid";
1229 $results = $DB->get_records_sql($sql, array('courseid' => $courseid));
1233 // The multiple qgo JOINS are necessary because we want timeclose/timelimit = 0 (unlimited) to supercede
1234 // any other group override.
1236 $sql = "SELECT q.id,
1237 COALESCE(v.oneclose, v.twoclose, v.threeclose, q.timeclose, 0) AS usertimeclose,
1238 COALESCE(v.onelimit, v.twolimit, v.threelimit, q.timelimit, 0) AS usertimelimit
1240 SELECT quiz.id AS quizid,
1241 MAX(quo.timeclose) AS oneclose, MAX(qgo1.timeclose) AS twoclose, MAX(qgo2.timeclose) AS threeclose,
1242 MAX(quo.timelimit) AS onelimit, MAX(qgo3.timelimit) AS twolimit, MAX(qgo4.timelimit) AS threelimit
1244 LEFT JOIN {quiz_overrides} quo ON quo.quiz = quiz.id
1245 LEFT JOIN {groups_members} gm ON gm.userid = quo.userid
1246 LEFT JOIN {quiz_overrides} qgo1 ON qgo1.timeclose = 0 AND qgo1.quiz = quiz.id
1247 LEFT JOIN {quiz_overrides} qgo2 ON qgo2.timeclose > 0 AND qgo2.quiz = quiz.id
1248 LEFT JOIN {quiz_overrides} qgo3 ON qgo3.timelimit = 0 AND qgo3.quiz = quiz.id
1249 LEFT JOIN {quiz_overrides} qgo4 ON qgo4.timelimit > 0 AND qgo4.quiz = quiz.id
1250 AND qgo1.groupid = gm.groupid
1251 AND qgo2.groupid = gm.groupid
1252 AND qgo3.groupid = gm.groupid
1253 AND qgo4.groupid = gm.groupid
1254 WHERE quiz.course = :courseid
1255 AND ((quo.userid = :userid) OR ((gm.userid IS NULL) AND (quo.userid IS NULL)))
1257 JOIN {quiz} q ON q.id = v.quizid";
1259 $results = $DB->get_records_sql($sql, array('courseid' => $courseid, 'userid' => $USER->id
));
1264 * Get the choices to offer for the 'Questions per page' option.
1265 * @return array int => string.
1267 function quiz_questions_per_page_options() {
1268 $pageoptions = array();
1269 $pageoptions[0] = get_string('neverallononepage', 'quiz');
1270 $pageoptions[1] = get_string('everyquestion', 'quiz');
1271 for ($i = 2; $i <= QUIZ_MAX_QPP_OPTION
; ++
$i) {
1272 $pageoptions[$i] = get_string('everynquestions', 'quiz', $i);
1274 return $pageoptions;
1278 * Get the human-readable name for a quiz attempt state.
1279 * @param string $state one of the state constants like {@link quiz_attempt::IN_PROGRESS}.
1280 * @return string The lang string to describe that state.
1282 function quiz_attempt_state_name($state) {
1284 case quiz_attempt
::IN_PROGRESS
:
1285 return get_string('stateinprogress', 'quiz');
1286 case quiz_attempt
::OVERDUE
:
1287 return get_string('stateoverdue', 'quiz');
1288 case quiz_attempt
::FINISHED
:
1289 return get_string('statefinished', 'quiz');
1290 case quiz_attempt
::ABANDONED
:
1291 return get_string('stateabandoned', 'quiz');
1293 throw new coding_exception('Unknown quiz attempt state.');
1297 // Other quiz functions ////////////////////////////////////////////////////////
1300 * @param object $quiz the quiz.
1301 * @param int $cmid the course_module object for this quiz.
1302 * @param object $question the question.
1303 * @param string $returnurl url to return to after action is done.
1304 * @param int $variant which question variant to preview (optional).
1305 * @return string html for a number of icons linked to action pages for a
1306 * question - preview and edit / view icons depending on user capabilities.
1308 function quiz_question_action_icons($quiz, $cmid, $question, $returnurl, $variant = null) {
1309 $html = quiz_question_preview_button($quiz, $question, false, $variant) . ' ' .
1310 quiz_question_edit_button($cmid, $question, $returnurl);
1315 * @param int $cmid the course_module.id for this quiz.
1316 * @param object $question the question.
1317 * @param string $returnurl url to return to after action is done.
1318 * @param string $contentbeforeicon some HTML content to be added inside the link, before the icon.
1319 * @return the HTML for an edit icon, view icon, or nothing for a question
1320 * (depending on permissions).
1322 function quiz_question_edit_button($cmid, $question, $returnurl, $contentaftericon = '') {
1323 global $CFG, $OUTPUT;
1325 // Minor efficiency saving. Only get strings once, even if there are a lot of icons on one page.
1326 static $stredit = null;
1327 static $strview = null;
1328 if ($stredit === null) {
1329 $stredit = get_string('edit');
1330 $strview = get_string('view');
1333 // What sort of icon should we show?
1335 if (!empty($question->id
) &&
1336 (question_has_capability_on($question, 'edit') ||
1337 question_has_capability_on($question, 'move'))) {
1340 } else if (!empty($question->id
) &&
1341 question_has_capability_on($question, 'view')) {
1348 if ($returnurl instanceof moodle_url
) {
1349 $returnurl = $returnurl->out_as_local_url(false);
1351 $questionparams = array('returnurl' => $returnurl, 'cmid' => $cmid, 'id' => $question->id
);
1352 $questionurl = new moodle_url("$CFG->wwwroot/question/question.php", $questionparams);
1353 return '<a title="' . $action . '" href="' . $questionurl->out() . '" class="questioneditbutton">' .
1354 $OUTPUT->pix_icon($icon, $action) . $contentaftericon .
1356 } else if ($contentaftericon) {
1357 return '<span class="questioneditbutton">' . $contentaftericon . '</span>';
1364 * @param object $quiz the quiz settings
1365 * @param object $question the question
1366 * @param int $variant which question variant to preview (optional).
1367 * @return moodle_url to preview this question with the options from this quiz.
1369 function quiz_question_preview_url($quiz, $question, $variant = null) {
1370 // Get the appropriate display options.
1371 $displayoptions = mod_quiz_display_options
::make_from_quiz($quiz,
1372 mod_quiz_display_options
::DURING
);
1375 if (isset($question->maxmark
)) {
1376 $maxmark = $question->maxmark
;
1379 // Work out the correcte preview URL.
1380 return question_preview_url($question->id
, $quiz->preferredbehaviour
,
1381 $maxmark, $displayoptions, $variant);
1385 * @param object $quiz the quiz settings
1386 * @param object $question the question
1387 * @param bool $label if true, show the preview question label after the icon
1388 * @param int $variant which question variant to preview (optional).
1389 * @return the HTML for a preview question icon.
1391 function quiz_question_preview_button($quiz, $question, $label = false, $variant = null) {
1393 if (!question_has_capability_on($question, 'use')) {
1397 return $PAGE->get_renderer('mod_quiz', 'edit')->question_preview_icon($quiz, $question, $label, $variant);
1401 * @param object $attempt the attempt.
1402 * @param object $context the quiz context.
1403 * @return int whether flags should be shown/editable to the current user for this attempt.
1405 function quiz_get_flag_option($attempt, $context) {
1407 if (!has_capability('moodle/question:flag', $context)) {
1408 return question_display_options
::HIDDEN
;
1409 } else if ($attempt->userid
== $USER->id
) {
1410 return question_display_options
::EDITABLE
;
1412 return question_display_options
::VISIBLE
;
1417 * Work out what state this quiz attempt is in - in the sense used by
1418 * quiz_get_review_options, not in the sense of $attempt->state.
1419 * @param object $quiz the quiz settings
1420 * @param object $attempt the quiz_attempt database row.
1421 * @return int one of the mod_quiz_display_options::DURING,
1422 * IMMEDIATELY_AFTER, LATER_WHILE_OPEN or AFTER_CLOSE constants.
1424 function quiz_attempt_state($quiz, $attempt) {
1425 if ($attempt->state
== quiz_attempt
::IN_PROGRESS
) {
1426 return mod_quiz_display_options
::DURING
;
1427 } else if ($quiz->timeclose
&& time() >= $quiz->timeclose
) {
1428 return mod_quiz_display_options
::AFTER_CLOSE
;
1429 } else if (time() < $attempt->timefinish +
120) {
1430 return mod_quiz_display_options
::IMMEDIATELY_AFTER
;
1432 return mod_quiz_display_options
::LATER_WHILE_OPEN
;
1437 * The the appropraite mod_quiz_display_options object for this attempt at this
1440 * @param object $quiz the quiz instance.
1441 * @param object $attempt the attempt in question.
1442 * @param $context the quiz context.
1444 * @return mod_quiz_display_options
1446 function quiz_get_review_options($quiz, $attempt, $context) {
1447 $options = mod_quiz_display_options
::make_from_quiz($quiz, quiz_attempt_state($quiz, $attempt));
1449 $options->readonly
= true;
1450 $options->flags
= quiz_get_flag_option($attempt, $context);
1451 if (!empty($attempt->id
)) {
1452 $options->questionreviewlink
= new moodle_url('/mod/quiz/reviewquestion.php',
1453 array('attempt' => $attempt->id
));
1456 // Show a link to the comment box only for closed attempts.
1457 if (!empty($attempt->id
) && $attempt->state
== quiz_attempt
::FINISHED
&& !$attempt->preview
&&
1458 !is_null($context) && has_capability('mod/quiz:grade', $context)) {
1459 $options->manualcomment
= question_display_options
::VISIBLE
;
1460 $options->manualcommentlink
= new moodle_url('/mod/quiz/comment.php',
1461 array('attempt' => $attempt->id
));
1464 if (!is_null($context) && !$attempt->preview
&&
1465 has_capability('mod/quiz:viewreports', $context) &&
1466 has_capability('moodle/grade:viewhidden', $context)) {
1467 // People who can see reports and hidden grades should be shown everything,
1468 // except during preview when teachers want to see what students see.
1469 $options->attempt
= question_display_options
::VISIBLE
;
1470 $options->correctness
= question_display_options
::VISIBLE
;
1471 $options->marks
= question_display_options
::MARK_AND_MAX
;
1472 $options->feedback
= question_display_options
::VISIBLE
;
1473 $options->numpartscorrect
= question_display_options
::VISIBLE
;
1474 $options->manualcomment
= question_display_options
::VISIBLE
;
1475 $options->generalfeedback
= question_display_options
::VISIBLE
;
1476 $options->rightanswer
= question_display_options
::VISIBLE
;
1477 $options->overallfeedback
= question_display_options
::VISIBLE
;
1478 $options->history
= question_display_options
::VISIBLE
;
1486 * Combines the review options from a number of different quiz attempts.
1487 * Returns an array of two ojects, so the suggested way of calling this
1489 * list($someoptions, $alloptions) = quiz_get_combined_reviewoptions(...)
1491 * @param object $quiz the quiz instance.
1492 * @param array $attempts an array of attempt objects.
1494 * @return array of two options objects, one showing which options are true for
1495 * at least one of the attempts, the other showing which options are true
1498 function quiz_get_combined_reviewoptions($quiz, $attempts) {
1499 $fields = array('feedback', 'generalfeedback', 'rightanswer', 'overallfeedback');
1500 $someoptions = new stdClass();
1501 $alloptions = new stdClass();
1502 foreach ($fields as $field) {
1503 $someoptions->$field = false;
1504 $alloptions->$field = true;
1506 $someoptions->marks
= question_display_options
::HIDDEN
;
1507 $alloptions->marks
= question_display_options
::MARK_AND_MAX
;
1509 // This shouldn't happen, but we need to prevent reveal information.
1510 if (empty($attempts)) {
1511 return array($someoptions, $someoptions);
1514 foreach ($attempts as $attempt) {
1515 $attemptoptions = mod_quiz_display_options
::make_from_quiz($quiz,
1516 quiz_attempt_state($quiz, $attempt));
1517 foreach ($fields as $field) {
1518 $someoptions->$field = $someoptions->$field ||
$attemptoptions->$field;
1519 $alloptions->$field = $alloptions->$field && $attemptoptions->$field;
1521 $someoptions->marks
= max($someoptions->marks
, $attemptoptions->marks
);
1522 $alloptions->marks
= min($alloptions->marks
, $attemptoptions->marks
);
1524 return array($someoptions, $alloptions);
1527 // Functions for sending notification messages /////////////////////////////////
1530 * Sends a confirmation message to the student confirming that the attempt was processed.
1532 * @param object $a lots of useful information that can be used in the message
1535 * @return int|false as for {@link message_send()}.
1537 function quiz_send_confirmation($recipient, $a) {
1539 // Add information about the recipient to $a.
1540 // Don't do idnumber. we want idnumber to be the submitter's idnumber.
1541 $a->username
= fullname($recipient);
1542 $a->userusername
= $recipient->username
;
1544 // Prepare the message.
1545 $eventdata = new \core\message\
message();
1546 $eventdata->courseid
= $a->courseid
;
1547 $eventdata->component
= 'mod_quiz';
1548 $eventdata->name
= 'confirmation';
1549 $eventdata->notification
= 1;
1551 $eventdata->userfrom
= core_user
::get_noreply_user();
1552 $eventdata->userto
= $recipient;
1553 $eventdata->subject
= get_string('emailconfirmsubject', 'quiz', $a);
1554 $eventdata->fullmessage
= get_string('emailconfirmbody', 'quiz', $a);
1555 $eventdata->fullmessageformat
= FORMAT_PLAIN
;
1556 $eventdata->fullmessagehtml
= '';
1558 $eventdata->smallmessage
= get_string('emailconfirmsmall', 'quiz', $a);
1559 $eventdata->contexturl
= $a->quizurl
;
1560 $eventdata->contexturlname
= $a->quizname
;
1563 return message_send($eventdata);
1567 * Sends notification messages to the interested parties that assign the role capability
1569 * @param object $recipient user object of the intended recipient
1570 * @param object $a associative array of replaceable fields for the templates
1572 * @return int|false as for {@link message_send()}.
1574 function quiz_send_notification($recipient, $submitter, $a) {
1576 // Recipient info for template.
1577 $a->useridnumber
= $recipient->idnumber
;
1578 $a->username
= fullname($recipient);
1579 $a->userusername
= $recipient->username
;
1581 // Prepare the message.
1582 $eventdata = new \core\message\
message();
1583 $eventdata->courseid
= $a->courseid
;
1584 $eventdata->component
= 'mod_quiz';
1585 $eventdata->name
= 'submission';
1586 $eventdata->notification
= 1;
1588 $eventdata->userfrom
= $submitter;
1589 $eventdata->userto
= $recipient;
1590 $eventdata->subject
= get_string('emailnotifysubject', 'quiz', $a);
1591 $eventdata->fullmessage
= get_string('emailnotifybody', 'quiz', $a);
1592 $eventdata->fullmessageformat
= FORMAT_PLAIN
;
1593 $eventdata->fullmessagehtml
= '';
1595 $eventdata->smallmessage
= get_string('emailnotifysmall', 'quiz', $a);
1596 $eventdata->contexturl
= $a->quizreviewurl
;
1597 $eventdata->contexturlname
= $a->quizname
;
1600 return message_send($eventdata);
1604 * Send all the requried messages when a quiz attempt is submitted.
1606 * @param object $course the course
1607 * @param object $quiz the quiz
1608 * @param object $attempt this attempt just finished
1609 * @param object $context the quiz context
1610 * @param object $cm the coursemodule for this quiz
1612 * @return bool true if all necessary messages were sent successfully, else false.
1614 function quiz_send_notification_messages($course, $quiz, $attempt, $context, $cm) {
1617 // Do nothing if required objects not present.
1618 if (empty($course) or empty($quiz) or empty($attempt) or empty($context)) {
1619 throw new coding_exception('$course, $quiz, $attempt, $context and $cm must all be set.');
1622 $submitter = $DB->get_record('user', array('id' => $attempt->userid
), '*', MUST_EXIST
);
1624 // Check for confirmation required.
1625 $sendconfirm = false;
1626 $notifyexcludeusers = '';
1627 if (has_capability('mod/quiz:emailconfirmsubmission', $context, $submitter, false)) {
1628 $notifyexcludeusers = $submitter->id
;
1629 $sendconfirm = true;
1632 // Check for notifications required.
1633 $notifyfields = 'u.id, u.username, u.idnumber, u.email, u.emailstop, u.lang,
1634 u.timezone, u.mailformat, u.maildisplay, u.auth, u.suspended, u.deleted, ';
1635 $notifyfields .= get_all_user_name_fields(true, 'u');
1636 $groups = groups_get_all_groups($course->id
, $submitter->id
, $cm->groupingid
);
1637 if (is_array($groups) && count($groups) > 0) {
1638 $groups = array_keys($groups);
1639 } else if (groups_get_activity_groupmode($cm, $course) != NOGROUPS
) {
1640 // If the user is not in a group, and the quiz is set to group mode,
1641 // then set $groups to a non-existant id so that only users with
1642 // 'moodle/site:accessallgroups' get notified.
1647 $userstonotify = get_users_by_capability($context, 'mod/quiz:emailnotifysubmission',
1648 $notifyfields, '', '', '', $groups, $notifyexcludeusers, false, false, true);
1650 if (empty($userstonotify) && !$sendconfirm) {
1651 return true; // Nothing to do.
1654 $a = new stdClass();
1656 $a->courseid
= $course->id
;
1657 $a->coursename
= $course->fullname
;
1658 $a->courseshortname
= $course->shortname
;
1660 $a->quizname
= $quiz->name
;
1661 $a->quizreporturl
= $CFG->wwwroot
. '/mod/quiz/report.php?id=' . $cm->id
;
1662 $a->quizreportlink
= '<a href="' . $a->quizreporturl
. '">' .
1663 format_string($quiz->name
) . ' report</a>';
1664 $a->quizurl
= $CFG->wwwroot
. '/mod/quiz/view.php?id=' . $cm->id
;
1665 $a->quizlink
= '<a href="' . $a->quizurl
. '">' . format_string($quiz->name
) . '</a>';
1667 $a->submissiontime
= userdate($attempt->timefinish
);
1668 $a->timetaken
= format_time($attempt->timefinish
- $attempt->timestart
);
1669 $a->quizreviewurl
= $CFG->wwwroot
. '/mod/quiz/review.php?attempt=' . $attempt->id
;
1670 $a->quizreviewlink
= '<a href="' . $a->quizreviewurl
. '">' .
1671 format_string($quiz->name
) . ' review</a>';
1672 // Student who sat the quiz info.
1673 $a->studentidnumber
= $submitter->idnumber
;
1674 $a->studentname
= fullname($submitter);
1675 $a->studentusername
= $submitter->username
;
1679 // Send notifications if required.
1680 if (!empty($userstonotify)) {
1681 foreach ($userstonotify as $recipient) {
1682 $allok = $allok && quiz_send_notification($recipient, $submitter, $a);
1686 // Send confirmation if required. We send the student confirmation last, so
1687 // that if message sending is being intermittently buggy, which means we send
1688 // some but not all messages, and then try again later, then teachers may get
1689 // duplicate messages, but the student will always get exactly one.
1691 $allok = $allok && quiz_send_confirmation($submitter, $a);
1698 * Send the notification message when a quiz attempt becomes overdue.
1700 * @param quiz_attempt $attemptobj all the data about the quiz attempt.
1702 function quiz_send_overdue_message($attemptobj) {
1705 $submitter = $DB->get_record('user', array('id' => $attemptobj->get_userid()), '*', MUST_EXIST
);
1707 if (!$attemptobj->has_capability('mod/quiz:emailwarnoverdue', $submitter->id
, false)) {
1708 return; // Message not required.
1711 if (!$attemptobj->has_response_to_at_least_one_graded_question()) {
1712 return; // Message not required.
1715 // Prepare lots of useful information that admins might want to include in
1716 // the email message.
1717 $quizname = format_string($attemptobj->get_quiz_name());
1719 $deadlines = array();
1720 if ($attemptobj->get_quiz()->timelimit
) {
1721 $deadlines[] = $attemptobj->get_attempt()->timestart +
$attemptobj->get_quiz()->timelimit
;
1723 if ($attemptobj->get_quiz()->timeclose
) {
1724 $deadlines[] = $attemptobj->get_quiz()->timeclose
;
1726 $duedate = min($deadlines);
1727 $graceend = $duedate +
$attemptobj->get_quiz()->graceperiod
;
1729 $a = new stdClass();
1731 $a->courseid
= $attemptobj->get_course()->id
;
1732 $a->coursename
= format_string($attemptobj->get_course()->fullname
);
1733 $a->courseshortname
= format_string($attemptobj->get_course()->shortname
);
1735 $a->quizname
= $quizname;
1736 $a->quizurl
= $attemptobj->view_url();
1737 $a->quizlink
= '<a href="' . $a->quizurl
. '">' . $quizname . '</a>';
1739 $a->attemptduedate
= userdate($duedate);
1740 $a->attemptgraceend
= userdate($graceend);
1741 $a->attemptsummaryurl
= $attemptobj->summary_url()->out(false);
1742 $a->attemptsummarylink
= '<a href="' . $a->attemptsummaryurl
. '">' . $quizname . ' review</a>';
1744 $a->studentidnumber
= $submitter->idnumber
;
1745 $a->studentname
= fullname($submitter);
1746 $a->studentusername
= $submitter->username
;
1748 // Prepare the message.
1749 $eventdata = new \core\message\
message();
1750 $eventdata->courseid
= $a->courseid
;
1751 $eventdata->component
= 'mod_quiz';
1752 $eventdata->name
= 'attempt_overdue';
1753 $eventdata->notification
= 1;
1755 $eventdata->userfrom
= core_user
::get_noreply_user();
1756 $eventdata->userto
= $submitter;
1757 $eventdata->subject
= get_string('emailoverduesubject', 'quiz', $a);
1758 $eventdata->fullmessage
= get_string('emailoverduebody', 'quiz', $a);
1759 $eventdata->fullmessageformat
= FORMAT_PLAIN
;
1760 $eventdata->fullmessagehtml
= '';
1762 $eventdata->smallmessage
= get_string('emailoverduesmall', 'quiz', $a);
1763 $eventdata->contexturl
= $a->quizurl
;
1764 $eventdata->contexturlname
= $a->quizname
;
1766 // Send the message.
1767 return message_send($eventdata);
1771 * Handle the quiz_attempt_submitted event.
1773 * This sends the confirmation and notification messages, if required.
1775 * @param object $event the event object.
1777 function quiz_attempt_submitted_handler($event) {
1780 $course = $DB->get_record('course', array('id' => $event->courseid
));
1781 $attempt = $event->get_record_snapshot('quiz_attempts', $event->objectid
);
1782 $quiz = $event->get_record_snapshot('quiz', $attempt->quiz
);
1783 $cm = get_coursemodule_from_id('quiz', $event->get_context()->instanceid
, $event->courseid
);
1785 if (!($course && $quiz && $cm && $attempt)) {
1786 // Something has been deleted since the event was raised. Therefore, the
1787 // event is no longer relevant.
1791 // Update completion state.
1792 $completion = new completion_info($course);
1793 if ($completion->is_enabled($cm) && ($quiz->completionattemptsexhausted ||
$quiz->completionpass
)) {
1794 $completion->update_state($cm, COMPLETION_COMPLETE
, $event->userid
);
1796 return quiz_send_notification_messages($course, $quiz, $attempt,
1797 context_module
::instance($cm->id
), $cm);
1801 * Handle groups_member_added event
1803 * @param object $event the event object.
1804 * @deprecated since 2.6, see {@link \mod_quiz\group_observers::group_member_added()}.
1806 function quiz_groups_member_added_handler($event) {
1807 debugging('quiz_groups_member_added_handler() is deprecated, please use ' .
1808 '\mod_quiz\group_observers::group_member_added() instead.', DEBUG_DEVELOPER
);
1809 quiz_update_open_attempts(array('userid'=>$event->userid
, 'groupid'=>$event->groupid
));
1813 * Handle groups_member_removed event
1815 * @param object $event the event object.
1816 * @deprecated since 2.6, see {@link \mod_quiz\group_observers::group_member_removed()}.
1818 function quiz_groups_member_removed_handler($event) {
1819 debugging('quiz_groups_member_removed_handler() is deprecated, please use ' .
1820 '\mod_quiz\group_observers::group_member_removed() instead.', DEBUG_DEVELOPER
);
1821 quiz_update_open_attempts(array('userid'=>$event->userid
, 'groupid'=>$event->groupid
));
1825 * Handle groups_group_deleted event
1827 * @param object $event the event object.
1828 * @deprecated since 2.6, see {@link \mod_quiz\group_observers::group_deleted()}.
1830 function quiz_groups_group_deleted_handler($event) {
1832 debugging('quiz_groups_group_deleted_handler() is deprecated, please use ' .
1833 '\mod_quiz\group_observers::group_deleted() instead.', DEBUG_DEVELOPER
);
1834 quiz_process_group_deleted_in_course($event->courseid
);
1838 * Logic to happen when a/some group(s) has/have been deleted in a course.
1840 * @param int $courseid The course ID.
1843 function quiz_process_group_deleted_in_course($courseid) {
1846 // It would be nice if we got the groupid that was deleted.
1847 // Instead, we just update all quizzes with orphaned group overrides.
1848 $sql = "SELECT o.id, o.quiz
1849 FROM {quiz_overrides} o
1850 JOIN {quiz} quiz ON quiz.id = o.quiz
1851 LEFT JOIN {groups} grp ON grp.id = o.groupid
1852 WHERE quiz.course = :courseid
1853 AND o.groupid IS NOT NULL
1854 AND grp.id IS NULL";
1855 $params = array('courseid' => $courseid);
1856 $records = $DB->get_records_sql_menu($sql, $params);
1858 return; // Nothing to do.
1860 $DB->delete_records_list('quiz_overrides', 'id', array_keys($records));
1861 quiz_update_open_attempts(array('quizid' => array_unique(array_values($records))));
1865 * Handle groups_members_removed event
1867 * @param object $event the event object.
1868 * @deprecated since 2.6, see {@link \mod_quiz\group_observers::group_member_removed()}.
1870 function quiz_groups_members_removed_handler($event) {
1871 debugging('quiz_groups_members_removed_handler() is deprecated, please use ' .
1872 '\mod_quiz\group_observers::group_member_removed() instead.', DEBUG_DEVELOPER
);
1873 if ($event->userid
== 0) {
1874 quiz_update_open_attempts(array('courseid'=>$event->courseid
));
1876 quiz_update_open_attempts(array('courseid'=>$event->courseid
, 'userid'=>$event->userid
));
1881 * Get the information about the standard quiz JavaScript module.
1882 * @return array a standard jsmodule structure.
1884 function quiz_get_js_module() {
1888 'name' => 'mod_quiz',
1889 'fullpath' => '/mod/quiz/module.js',
1890 'requires' => array('base', 'dom', 'event-delegate', 'event-key',
1891 'core_question_engine', 'moodle-core-formchangechecker'),
1893 array('cancel', 'moodle'),
1894 array('flagged', 'question'),
1895 array('functiondisabledbysecuremode', 'quiz'),
1896 array('startattempt', 'quiz'),
1897 array('timesup', 'quiz'),
1898 array('changesmadereallygoaway', 'moodle'),
1905 * An extension of question_display_options that includes the extra options used
1908 * @copyright 2010 The Open University
1909 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
1911 class mod_quiz_display_options
extends question_display_options
{
1913 * @var integer bits used to indicate various times in relation to a
1916 const DURING
= 0x10000;
1917 const IMMEDIATELY_AFTER
= 0x01000;
1918 const LATER_WHILE_OPEN
= 0x00100;
1919 const AFTER_CLOSE
= 0x00010;
1923 * @var boolean if this is false, then the student is not allowed to review
1924 * anything about the attempt.
1926 public $attempt = true;
1929 * @var boolean if this is false, then the student is not allowed to review
1930 * anything about the attempt.
1932 public $overallfeedback = self
::VISIBLE
;
1935 * Set up the various options from the quiz settings, and a time constant.
1936 * @param object $quiz the quiz settings.
1937 * @param int $one of the {@link DURING}, {@link IMMEDIATELY_AFTER},
1938 * {@link LATER_WHILE_OPEN} or {@link AFTER_CLOSE} constants.
1939 * @return mod_quiz_display_options set up appropriately.
1941 public static function make_from_quiz($quiz, $when) {
1942 $options = new self();
1944 $options->attempt
= self
::extract($quiz->reviewattempt
, $when, true, false);
1945 $options->correctness
= self
::extract($quiz->reviewcorrectness
, $when);
1946 $options->marks
= self
::extract($quiz->reviewmarks
, $when,
1947 self
::MARK_AND_MAX
, self
::MAX_ONLY
);
1948 $options->feedback
= self
::extract($quiz->reviewspecificfeedback
, $when);
1949 $options->generalfeedback
= self
::extract($quiz->reviewgeneralfeedback
, $when);
1950 $options->rightanswer
= self
::extract($quiz->reviewrightanswer
, $when);
1951 $options->overallfeedback
= self
::extract($quiz->reviewoverallfeedback
, $when);
1953 $options->numpartscorrect
= $options->feedback
;
1954 $options->manualcomment
= $options->feedback
;
1956 if ($quiz->questiondecimalpoints
!= -1) {
1957 $options->markdp
= $quiz->questiondecimalpoints
;
1959 $options->markdp
= $quiz->decimalpoints
;
1965 protected static function extract($bitmask, $bit,
1966 $whenset = self
::VISIBLE
, $whennotset = self
::HIDDEN
) {
1967 if ($bitmask & $bit) {
1976 * A {@link qubaid_condition} for finding all the question usages belonging to
1977 * a particular quiz.
1979 * @copyright 2010 The Open University
1980 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
1982 class qubaids_for_quiz
extends qubaid_join
{
1983 public function __construct($quizid, $includepreviews = true, $onlyfinished = false) {
1984 $where = 'quiza.quiz = :quizaquiz';
1985 $params = array('quizaquiz' => $quizid);
1987 if (!$includepreviews) {
1988 $where .= ' AND preview = 0';
1991 if ($onlyfinished) {
1992 $where .= ' AND state = :statefinished';
1993 $params['statefinished'] = quiz_attempt
::FINISHED
;
1996 parent
::__construct('{quiz_attempts} quiza', 'quiza.uniqueid', $where, $params);
2001 * A {@link qubaid_condition} for finding all the question usages belonging to a particular user and quiz combination.
2003 * @copyright 2018 Andrew Nicols <andrwe@nicols.co.uk>
2004 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
2006 class qubaids_for_quiz_user
extends qubaid_join
{
2008 * Constructor for this qubaid.
2010 * @param int $quizid The quiz to search.
2011 * @param int $userid The user to filter on
2012 * @param bool $includepreviews Whether to include preview attempts
2013 * @param bool $onlyfinished Whether to only include finished attempts or not
2015 public function __construct($quizid, $userid, $includepreviews = true, $onlyfinished = false) {
2016 $where = 'quiza.quiz = :quizaquiz AND quiza.userid = :quizauserid';
2018 'quizaquiz' => $quizid,
2019 'quizauserid' => $userid,
2022 if (!$includepreviews) {
2023 $where .= ' AND preview = 0';
2026 if ($onlyfinished) {
2027 $where .= ' AND state = :statefinished';
2028 $params['statefinished'] = quiz_attempt
::FINISHED
;
2031 parent
::__construct('{quiz_attempts} quiza', 'quiza.uniqueid', $where, $params);
2036 * Creates a textual representation of a question for display.
2038 * @param object $question A question object from the database questions table
2039 * @param bool $showicon If true, show the question's icon with the question. False by default.
2040 * @param bool $showquestiontext If true (default), show question text after question name.
2041 * If false, show only question name.
2044 function quiz_question_tostring($question, $showicon = false, $showquestiontext = true) {
2047 $name = shorten_text(format_string($question->name
), 200);
2049 $name .= print_question_icon($question) . ' ' . $name;
2051 $result .= html_writer
::span($name, 'questionname');
2053 if ($showquestiontext) {
2054 $questiontext = question_utils
::to_plain_text($question->questiontext
,
2055 $question->questiontextformat
, array('noclean' => true, 'para' => false));
2056 $questiontext = shorten_text($questiontext, 200);
2057 if ($questiontext) {
2058 $result .= ' ' . html_writer
::span(s($questiontext), 'questiontext');
2066 * Verify that the question exists, and the user has permission to use it.
2067 * Does not return. Throws an exception if the question cannot be used.
2068 * @param int $questionid The id of the question.
2070 function quiz_require_question_use($questionid) {
2072 $question = $DB->get_record('question', array('id' => $questionid), '*', MUST_EXIST
);
2073 question_require_capability_on($question, 'use');
2077 * Verify that the question exists, and the user has permission to use it.
2078 * @param object $quiz the quiz settings.
2079 * @param int $slot which question in the quiz to test.
2080 * @return bool whether the user can use this question.
2082 function quiz_has_question_use($quiz, $slot) {
2084 $question = $DB->get_record_sql("
2086 FROM {quiz_slots} slot
2087 JOIN {question} q ON q.id = slot.questionid
2088 WHERE slot.quizid = ? AND slot.slot = ?", array($quiz->id
, $slot));
2092 return question_has_capability_on($question, 'use');
2096 * Add a question to a quiz
2098 * Adds a question to a quiz by updating $quiz as well as the
2099 * quiz and quiz_slots tables. It also adds a page break if required.
2100 * @param int $questionid The id of the question to be added
2101 * @param object $quiz The extended quiz object as used by edit.php
2102 * This is updated by this function
2103 * @param int $page Which page in quiz to add the question on. If 0 (default),
2105 * @param float $maxmark The maximum mark to set for this question. (Optional,
2106 * defaults to question.defaultmark.
2107 * @return bool false if the question was already in the quiz
2109 function quiz_add_quiz_question($questionid, $quiz, $page = 0, $maxmark = null) {
2112 // Make sue the question is not of the "random" type.
2113 $questiontype = $DB->get_field('question', 'qtype', array('id' => $questionid));
2114 if ($questiontype == 'random') {
2115 throw new coding_exception(
2116 'Adding "random" questions via quiz_add_quiz_question() is deprecated. Please use quiz_add_random_questions().'
2120 $slots = $DB->get_records('quiz_slots', array('quizid' => $quiz->id
),
2121 'slot', 'questionid, slot, page, id');
2122 if (array_key_exists($questionid, $slots)) {
2126 $trans = $DB->start_delegated_transaction();
2130 foreach ($slots as $slot) {
2131 if ($slot->page
> $maxpage) {
2132 $maxpage = $slot->page
;
2135 $numonlastpage +
= 1;
2139 // Add the new question instance.
2140 $slot = new stdClass();
2141 $slot->quizid
= $quiz->id
;
2142 $slot->questionid
= $questionid;
2144 if ($maxmark !== null) {
2145 $slot->maxmark
= $maxmark;
2147 $slot->maxmark
= $DB->get_field('question', 'defaultmark', array('id' => $questionid));
2150 if (is_int($page) && $page >= 1) {
2151 // Adding on a given page.
2152 $lastslotbefore = 0;
2153 foreach (array_reverse($slots) as $otherslot) {
2154 if ($otherslot->page
> $page) {
2155 $DB->set_field('quiz_slots', 'slot', $otherslot->slot +
1, array('id' => $otherslot->id
));
2157 $lastslotbefore = $otherslot->slot
;
2161 $slot->slot
= $lastslotbefore +
1;
2162 $slot->page
= min($page, $maxpage +
1);
2164 quiz_update_section_firstslots($quiz->id
, 1, max($lastslotbefore, 1));
2167 $lastslot = end($slots);
2169 $slot->slot
= $lastslot->slot +
1;
2173 if ($quiz->questionsperpage
&& $numonlastpage >= $quiz->questionsperpage
) {
2174 $slot->page
= $maxpage +
1;
2176 $slot->page
= $maxpage;
2180 $DB->insert_record('quiz_slots', $slot);
2181 $trans->allow_commit();
2185 * Move all the section headings in a certain slot range by a certain offset.
2187 * @param int $quizid the id of a quiz
2188 * @param int $direction amount to adjust section heading positions. Normally +1 or -1.
2189 * @param int $afterslot adjust headings that start after this slot.
2190 * @param int|null $beforeslot optionally, only adjust headings before this slot.
2192 function quiz_update_section_firstslots($quizid, $direction, $afterslot, $beforeslot = null) {
2194 $where = 'quizid = ? AND firstslot > ?';
2195 $params = [$direction, $quizid, $afterslot];
2197 $where .= ' AND firstslot < ?';
2198 $params[] = $beforeslot;
2200 $firstslotschanges = $DB->get_records_select_menu('quiz_sections',
2201 $where, $params, '', 'firstslot, firstslot + ?');
2202 update_field_with_unique_index('quiz_sections', 'firstslot', $firstslotschanges, ['quizid' => $quizid]);
2206 * Add a random question to the quiz at a given point.
2207 * @param stdClass $quiz the quiz settings.
2208 * @param int $addonpage the page on which to add the question.
2209 * @param int $categoryid the question category to add the question from.
2210 * @param int $number the number of random questions to add.
2211 * @param bool $includesubcategories whether to include questoins from subcategories.
2212 * @param int[] $tagids Array of tagids. The question that will be picked randomly should be tagged with all these tags.
2214 function quiz_add_random_questions($quiz, $addonpage, $categoryid, $number,
2215 $includesubcategories, $tagids = []) {
2218 $category = $DB->get_record('question_categories', array('id' => $categoryid));
2220 print_error('invalidcategoryid', 'error');
2223 $catcontext = context
::instance_by_id($category->contextid
);
2224 require_capability('moodle/question:useall', $catcontext);
2226 $tags = \core_tag_tag
::get_bulk($tagids, 'id, name');
2228 foreach ($tags as $tag) {
2229 $tagstrings[] = "{$tag->id},{$tag->name}";
2232 // Find existing random questions in this category that are
2233 // not used by any quiz.
2234 $existingquestions = $DB->get_records_sql(
2235 "SELECT q.id, q.qtype FROM {question} q
2236 WHERE qtype = 'random'
2238 AND " . $DB->sql_compare_text('questiontext') . " = ?
2242 WHERE questionid = q.id)
2243 ORDER BY id", array($category->id
, $includesubcategories ?
'1' : '0'));
2245 for ($i = 0; $i < $number; $i++
) {
2246 // Take as many of orphaned "random" questions as needed.
2247 if (!$question = array_shift($existingquestions)) {
2248 $form = new stdClass();
2249 $form->category
= $category->id
. ',' . $category->contextid
;
2250 $form->includesubcategories
= $includesubcategories;
2251 $form->fromtags
= $tagstrings;
2252 $form->defaultmark
= 1;
2254 $form->stamp
= make_unique_id_code(); // Set the unique code (not to be changed).
2255 $question = new stdClass();
2256 $question->qtype
= 'random';
2257 $question = question_bank
::get_qtype('random')->save_question($question, $form);
2258 if (!isset($question->id
)) {
2259 print_error('cannotinsertrandomquestion', 'quiz');
2263 $randomslotdata = new stdClass();
2264 $randomslotdata->quizid
= $quiz->id
;
2265 $randomslotdata->questionid
= $question->id
;
2266 $randomslotdata->questioncategoryid
= $categoryid;
2267 $randomslotdata->includingsubcategories
= $includesubcategories ?
1 : 0;
2268 $randomslotdata->maxmark
= 1;
2270 $randomslot = new \mod_quiz\local\structure\
slot_random($randomslotdata);
2271 $randomslot->set_quiz($quiz);
2272 $randomslot->set_tags($tags);
2273 $randomslot->insert($addonpage);
2278 * Mark the activity completed (if required) and trigger the course_module_viewed event.
2280 * @param stdClass $quiz quiz object
2281 * @param stdClass $course course object
2282 * @param stdClass $cm course module object
2283 * @param stdClass $context context object
2286 function quiz_view($quiz, $course, $cm, $context) {
2289 'objectid' => $quiz->id
,
2290 'context' => $context
2293 $event = \mod_quiz\event\course_module_viewed
::create($params);
2294 $event->add_record_snapshot('quiz', $quiz);
2298 $completion = new completion_info($course);
2299 $completion->set_module_viewed($cm);
2303 * Validate permissions for creating a new attempt and start a new preview attempt if required.
2305 * @param quiz $quizobj quiz object
2306 * @param quiz_access_manager $accessmanager quiz access manager
2307 * @param bool $forcenew whether was required to start a new preview attempt
2308 * @param int $page page to jump to in the attempt
2309 * @param bool $redirect whether to redirect or throw exceptions (for web or ws usage)
2310 * @return array an array containing the attempt information, access error messages and the page to jump to in the attempt
2311 * @throws moodle_quiz_exception
2314 function quiz_validate_new_attempt(quiz
$quizobj, quiz_access_manager
$accessmanager, $forcenew, $page, $redirect) {
2318 if ($quizobj->is_preview_user() && $forcenew) {
2319 $accessmanager->current_attempt_finished();
2322 // Check capabilities.
2323 if (!$quizobj->is_preview_user()) {
2324 $quizobj->require_capability('mod/quiz:attempt');
2327 // Check to see if a new preview was requested.
2328 if ($quizobj->is_preview_user() && $forcenew) {
2329 // To force the creation of a new preview, we mark the current attempt (if any)
2330 // as finished. It will then automatically be deleted below.
2331 $DB->set_field('quiz_attempts', 'state', quiz_attempt
::FINISHED
,
2332 array('quiz' => $quizobj->get_quizid(), 'userid' => $USER->id
));
2335 // Look for an existing attempt.
2336 $attempts = quiz_get_user_attempts($quizobj->get_quizid(), $USER->id
, 'all', true);
2337 $lastattempt = end($attempts);
2339 $attemptnumber = null;
2340 // If an in-progress attempt exists, check password then redirect to it.
2341 if ($lastattempt && ($lastattempt->state
== quiz_attempt
::IN_PROGRESS ||
2342 $lastattempt->state
== quiz_attempt
::OVERDUE
)) {
2343 $currentattemptid = $lastattempt->id
;
2344 $messages = $accessmanager->prevent_access();
2346 // If the attempt is now overdue, deal with that.
2347 $quizobj->create_attempt_object($lastattempt)->handle_if_time_expired($timenow, true);
2349 // And, if the attempt is now no longer in progress, redirect to the appropriate place.
2350 if ($lastattempt->state
== quiz_attempt
::ABANDONED ||
$lastattempt->state
== quiz_attempt
::FINISHED
) {
2352 redirect($quizobj->review_url($lastattempt->id
));
2354 throw new moodle_quiz_exception($quizobj, 'attemptalreadyclosed');
2358 // If the page number was not explicitly in the URL, go to the current page.
2360 $page = $lastattempt->currentpage
;
2364 while ($lastattempt && $lastattempt->preview
) {
2365 $lastattempt = array_pop($attempts);
2368 // Get number for the next or unfinished attempt.
2370 $attemptnumber = $lastattempt->attempt +
1;
2372 $lastattempt = false;
2375 $currentattemptid = null;
2377 $messages = $accessmanager->prevent_access() +
2378 $accessmanager->prevent_new_attempt(count($attempts), $lastattempt);
2384 return array($currentattemptid, $attemptnumber, $lastattempt, $messages, $page);
2388 * Prepare and start a new attempt deleting the previous preview attempts.
2390 * @param quiz $quizobj quiz object
2391 * @param int $attemptnumber the attempt number
2392 * @param object $lastattempt last attempt object
2393 * @param bool $offlineattempt whether is an offline attempt or not
2394 * @return object the new attempt
2397 function quiz_prepare_and_start_new_attempt(quiz
$quizobj, $attemptnumber, $lastattempt, $offlineattempt = false) {
2400 // Delete any previous preview attempts belonging to this user.
2401 quiz_delete_previews($quizobj->get_quiz(), $USER->id
);
2403 $quba = question_engine
::make_questions_usage_by_activity('mod_quiz', $quizobj->get_context());
2404 $quba->set_preferred_behaviour($quizobj->get_quiz()->preferredbehaviour
);
2406 // Create the new attempt and initialize the question sessions
2407 $timenow = time(); // Update time now, in case the server is running really slowly.
2408 $attempt = quiz_create_attempt($quizobj, $attemptnumber, $lastattempt, $timenow, $quizobj->is_preview_user());
2410 if (!($quizobj->get_quiz()->attemptonlast
&& $lastattempt)) {
2411 $attempt = quiz_start_new_attempt($quizobj, $quba, $attempt, $attemptnumber, $timenow);
2413 $attempt = quiz_start_attempt_built_on_last($quba, $attempt, $lastattempt);
2416 $transaction = $DB->start_delegated_transaction();
2418 // Init the timemodifiedoffline for offline attempts.
2419 if ($offlineattempt) {
2420 $attempt->timemodifiedoffline
= $attempt->timemodified
;
2422 $attempt = quiz_attempt_save_started($quizobj, $quba, $attempt);
2424 $transaction->allow_commit();
2430 * Check if the given calendar_event is either a user or group override
2433 * @param calendar_event $event The calendar event to check
2436 function quiz_is_overriden_calendar_event(\calendar_event
$event) {
2439 if (!isset($event->modulename
)) {
2443 if ($event->modulename
!= 'quiz') {
2447 if (!isset($event->instance
)) {
2451 if (!isset($event->userid
) && !isset($event->groupid
)) {
2456 'quiz' => $event->instance
2459 if (isset($event->groupid
)) {
2460 $overrideparams['groupid'] = $event->groupid
;
2461 } else if (isset($event->userid
)) {
2462 $overrideparams['userid'] = $event->userid
;
2465 return $DB->record_exists('quiz_overrides', $overrideparams);
2469 * Retrieves tag information for the given list of quiz slot ids.
2470 * Currently the only slots that have tags are random question slots.
2473 * If we have 3 slots with id 1, 2, and 3. The first slot has two tags, the second
2474 * has one tag, and the third has zero tags. The return structure will look like:
2477 * { ...tag data... },
2478 * { ...tag data... },
2481 * { ...tag data... }
2486 * @param int[] $slotids The list of id for the quiz slots.
2487 * @return array[] List of quiz_slot_tags records indexed by slot id.
2489 function quiz_retrieve_tags_for_slot_ids($slotids) {
2492 if (empty($slotids)) {
2496 $slottags = $DB->get_records_list('quiz_slot_tags', 'slotid', $slotids);
2497 $tagsbyid = core_tag_tag
::get_bulk(array_filter(array_column($slottags, 'tagid')), 'id, name');
2498 $tagsbyname = false; // It will be loaded later if required.
2499 $emptytagids = array_reduce($slotids, function($carry, $slotid) {
2500 $carry[$slotid] = [];
2504 return array_reduce(
2506 function($carry, $slottag) use ($slottags, $tagsbyid, $tagsbyname) {
2507 if (isset($tagsbyid[$slottag->tagid
])) {
2508 // Make sure that we're returning the most updated tag name.
2509 $slottag->tagname
= $tagsbyid[$slottag->tagid
]->name
;
2511 if ($tagsbyname === false) {
2512 // We were hoping that this query could be avoided, but life
2513 // showed its other side to us!
2514 $tagcollid = core_tag_area
::get_collection('core', 'question');
2515 $tagsbyname = core_tag_tag
::get_by_name_bulk(
2517 array_column($slottags, 'tagname'),
2521 if (isset($tagsbyname[$slottag->tagname
])) {
2522 // Make sure that we're returning the current tag id that matches
2523 // the given tag name.
2524 $slottag->tagid
= $tagsbyname[$slottag->tagname
]->id
;
2526 // The tag does not exist anymore (neither the tag id nor the tag name
2527 // matches an existing tag).
2528 // We still need to include this row in the result as some callers might
2529 // be interested in these rows. An example is the editing forms that still
2530 // need to display tag names even if they don't exist anymore.
2531 $slottag->tagid
= null;
2535 $carry[$slottag->slotid
][] = $slottag;
2543 * Retrieves tag information for the given quiz slot.
2544 * A quiz slot have some tags if and only if it is representing a random question by tags.
2546 * @param int $slotid The id of the quiz slot.
2547 * @return stdClass[] List of quiz_slot_tags records.
2549 function quiz_retrieve_slot_tags($slotid) {
2550 $slottags = quiz_retrieve_tags_for_slot_ids([$slotid]);
2551 return $slottags[$slotid];
2555 * Retrieves tag ids for the given quiz slot.
2556 * A quiz slot have some tags if and only if it is representing a random question by tags.
2558 * @param int $slotid The id of the quiz slot.
2561 function quiz_retrieve_slot_tag_ids($slotid) {
2562 $tags = quiz_retrieve_slot_tags($slotid);
2564 // Only work with tags that exist.
2565 return array_filter(array_column($tags, 'tagid'));
2569 * Get quiz attempt and handling error.
2571 * @param int $attemptid the id of the current attempt.
2572 * @param int|null $cmid the course_module id for this quiz.
2573 * @return quiz_attempt $attemptobj all the data about the quiz attempt.
2574 * @throws moodle_exception
2576 function quiz_create_attempt_handling_errors($attemptid, $cmid = null) {
2578 $attempobj = quiz_attempt
::create($attemptid);
2579 } catch (moodle_exception
$e) {
2580 if (!empty($cmid)) {
2581 list($course, $cm) = get_course_and_cm_from_cmid($cmid, 'quiz');
2582 $continuelink = new moodle_url('/mod/quiz/view.php', array('id' => $cmid));
2583 $context = context_module
::instance($cm->id
);
2584 if (has_capability('mod/quiz:preview', $context)) {
2585 throw new moodle_exception('attempterrorcontentchange', 'quiz', $continuelink);
2587 throw new moodle_exception('attempterrorcontentchangeforuser', 'quiz', $continuelink);
2590 throw new moodle_exception('attempterrorinvalid', 'quiz');
2593 if (!empty($cmid) && $attempobj->get_cmid() != $cmid) {
2594 throw new moodle_exception('invalidcoursemodule');