MDL-69716 mod_quiz: No timestamp for in progress attempts
[moodle.git] / mod / quiz / lib.php
1 <?php
2 // This file is part of Moodle - http://moodle.org/
3 //
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.
8 //
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.
13 //
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/>.
17 /**
18  * Library of functions for the quiz module.
19  *
20  * This contains functions that are called also from outside the quiz module
21  * Functions that are only called by the quiz module itself are in {@link locallib.php}
22  *
23  * @package    mod_quiz
24  * @copyright  1999 onwards Martin Dougiamas {@link http://moodle.com}
25  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
26  */
29 defined('MOODLE_INTERNAL') || die();
31 require_once($CFG->dirroot . '/calendar/lib.php');
34 /**#@+
35  * Option controlling what options are offered on the quiz settings form.
36  */
37 define('QUIZ_MAX_ATTEMPT_OPTION', 10);
38 define('QUIZ_MAX_QPP_OPTION', 50);
39 define('QUIZ_MAX_DECIMAL_OPTION', 5);
40 define('QUIZ_MAX_Q_DECIMAL_OPTION', 7);
41 /**#@-*/
43 /**#@+
44  * Options determining how the grades from individual attempts are combined to give
45  * the overall grade for a user
46  */
47 define('QUIZ_GRADEHIGHEST', '1');
48 define('QUIZ_GRADEAVERAGE', '2');
49 define('QUIZ_ATTEMPTFIRST', '3');
50 define('QUIZ_ATTEMPTLAST',  '4');
51 /**#@-*/
53 /**
54  * @var int If start and end date for the quiz are more than this many seconds apart
55  * they will be represented by two separate events in the calendar
56  */
57 define('QUIZ_MAX_EVENT_LENGTH', 5*24*60*60); // 5 days.
59 /**#@+
60  * Options for navigation method within quizzes.
61  */
62 define('QUIZ_NAVMETHOD_FREE', 'free');
63 define('QUIZ_NAVMETHOD_SEQ',  'sequential');
64 /**#@-*/
66 /**
67  * Event types.
68  */
69 define('QUIZ_EVENT_TYPE_OPEN', 'open');
70 define('QUIZ_EVENT_TYPE_CLOSE', 'close');
72 /**
73  * Given an object containing all the necessary data,
74  * (defined by the form in mod_form.php) this function
75  * will create a new instance and return the id number
76  * of the new instance.
77  *
78  * @param object $quiz the data that came from the form.
79  * @return mixed the id of the new instance on success,
80  *          false or a string error message on failure.
81  */
82 function quiz_add_instance($quiz) {
83     global $DB;
84     $cmid = $quiz->coursemodule;
86     // Process the options from the form.
87     $quiz->timecreated = time();
88     $result = quiz_process_options($quiz);
89     if ($result && is_string($result)) {
90         return $result;
91     }
93     // Try to store it in the database.
94     $quiz->id = $DB->insert_record('quiz', $quiz);
96     // Create the first section for this quiz.
97     $DB->insert_record('quiz_sections', array('quizid' => $quiz->id,
98             'firstslot' => 1, 'heading' => '', 'shufflequestions' => 0));
100     // Do the processing required after an add or an update.
101     quiz_after_add_or_update($quiz);
103     return $quiz->id;
106 /**
107  * Given an object containing all the necessary data,
108  * (defined by the form in mod_form.php) this function
109  * will update an existing instance with new data.
110  *
111  * @param object $quiz the data that came from the form.
112  * @return mixed true on success, false or a string error message on failure.
113  */
114 function quiz_update_instance($quiz, $mform) {
115     global $CFG, $DB;
116     require_once($CFG->dirroot . '/mod/quiz/locallib.php');
118     // Process the options from the form.
119     $result = quiz_process_options($quiz);
120     if ($result && is_string($result)) {
121         return $result;
122     }
124     // Get the current value, so we can see what changed.
125     $oldquiz = $DB->get_record('quiz', array('id' => $quiz->instance));
127     // We need two values from the existing DB record that are not in the form,
128     // in some of the function calls below.
129     $quiz->sumgrades = $oldquiz->sumgrades;
130     $quiz->grade     = $oldquiz->grade;
132     // Update the database.
133     $quiz->id = $quiz->instance;
134     $DB->update_record('quiz', $quiz);
136     // Do the processing required after an add or an update.
137     quiz_after_add_or_update($quiz);
139     if ($oldquiz->grademethod != $quiz->grademethod) {
140         quiz_update_all_final_grades($quiz);
141         quiz_update_grades($quiz);
142     }
144     $quizdateschanged = $oldquiz->timelimit   != $quiz->timelimit
145                      || $oldquiz->timeclose   != $quiz->timeclose
146                      || $oldquiz->graceperiod != $quiz->graceperiod;
147     if ($quizdateschanged) {
148         quiz_update_open_attempts(array('quizid' => $quiz->id));
149     }
151     // Delete any previous preview attempts.
152     quiz_delete_previews($quiz);
154     // Repaginate, if asked to.
155     if (!empty($quiz->repaginatenow)) {
156         quiz_repaginate_questions($quiz->id, $quiz->questionsperpage);
157     }
159     return true;
162 /**
163  * Given an ID of an instance of this module,
164  * this function will permanently delete the instance
165  * and any data that depends on it.
166  *
167  * @param int $id the id of the quiz to delete.
168  * @return bool success or failure.
169  */
170 function quiz_delete_instance($id) {
171     global $DB;
173     $quiz = $DB->get_record('quiz', array('id' => $id), '*', MUST_EXIST);
175     quiz_delete_all_attempts($quiz);
176     quiz_delete_all_overrides($quiz);
178     // Look for random questions that may no longer be used when this quiz is gone.
179     $sql = "SELECT q.id
180               FROM {quiz_slots} slot
181               JOIN {question} q ON q.id = slot.questionid
182              WHERE slot.quizid = ? AND q.qtype = ?";
183     $questionids = $DB->get_fieldset_sql($sql, array($quiz->id, 'random'));
185     // We need to do the following deletes before we try and delete randoms, otherwise they would still be 'in use'.
186     $quizslots = $DB->get_fieldset_select('quiz_slots', 'id', 'quizid = ?', array($quiz->id));
187     $DB->delete_records_list('quiz_slot_tags', 'slotid', $quizslots);
188     $DB->delete_records('quiz_slots', array('quizid' => $quiz->id));
189     $DB->delete_records('quiz_sections', array('quizid' => $quiz->id));
191     foreach ($questionids as $questionid) {
192         question_delete_question($questionid);
193     }
195     $DB->delete_records('quiz_feedback', array('quizid' => $quiz->id));
197     quiz_access_manager::delete_settings($quiz);
199     $events = $DB->get_records('event', array('modulename' => 'quiz', 'instance' => $quiz->id));
200     foreach ($events as $event) {
201         $event = calendar_event::load($event);
202         $event->delete();
203     }
205     quiz_grade_item_delete($quiz);
206     // We must delete the module record after we delete the grade item.
207     $DB->delete_records('quiz', array('id' => $quiz->id));
209     return true;
212 /**
213  * Deletes a quiz override from the database and clears any corresponding calendar events
214  *
215  * @param object $quiz The quiz object.
216  * @param int $overrideid The id of the override being deleted
217  * @param bool $log Whether to trigger logs.
218  * @return bool true on success
219  */
220 function quiz_delete_override($quiz, $overrideid, $log = true) {
221     global $DB;
223     if (!isset($quiz->cmid)) {
224         $cm = get_coursemodule_from_instance('quiz', $quiz->id, $quiz->course);
225         $quiz->cmid = $cm->id;
226     }
228     $override = $DB->get_record('quiz_overrides', array('id' => $overrideid), '*', MUST_EXIST);
230     // Delete the events.
231     if (isset($override->groupid)) {
232         // Create the search array for a group override.
233         $eventsearcharray = array('modulename' => 'quiz',
234             'instance' => $quiz->id, 'groupid' => (int)$override->groupid);
235     } else {
236         // Create the search array for a user override.
237         $eventsearcharray = array('modulename' => 'quiz',
238             'instance' => $quiz->id, 'userid' => (int)$override->userid);
239     }
240     $events = $DB->get_records('event', $eventsearcharray);
241     foreach ($events as $event) {
242         $eventold = calendar_event::load($event);
243         $eventold->delete();
244     }
246     $DB->delete_records('quiz_overrides', array('id' => $overrideid));
248     if ($log) {
249         // Set the common parameters for one of the events we will be triggering.
250         $params = array(
251             'objectid' => $override->id,
252             'context' => context_module::instance($quiz->cmid),
253             'other' => array(
254                 'quizid' => $override->quiz
255             )
256         );
257         // Determine which override deleted event to fire.
258         if (!empty($override->userid)) {
259             $params['relateduserid'] = $override->userid;
260             $event = \mod_quiz\event\user_override_deleted::create($params);
261         } else {
262             $params['other']['groupid'] = $override->groupid;
263             $event = \mod_quiz\event\group_override_deleted::create($params);
264         }
266         // Trigger the override deleted event.
267         $event->add_record_snapshot('quiz_overrides', $override);
268         $event->trigger();
269     }
271     return true;
274 /**
275  * Deletes all quiz overrides from the database and clears any corresponding calendar events
276  *
277  * @param object $quiz The quiz object.
278  * @param bool $log Whether to trigger logs.
279  */
280 function quiz_delete_all_overrides($quiz, $log = true) {
281     global $DB;
283     $overrides = $DB->get_records('quiz_overrides', array('quiz' => $quiz->id), 'id');
284     foreach ($overrides as $override) {
285         quiz_delete_override($quiz, $override->id, $log);
286     }
289 /**
290  * Updates a quiz object with override information for a user.
291  *
292  * Algorithm:  For each quiz setting, if there is a matching user-specific override,
293  *   then use that otherwise, if there are group-specific overrides, return the most
294  *   lenient combination of them.  If neither applies, leave the quiz setting unchanged.
295  *
296  *   Special case: if there is more than one password that applies to the user, then
297  *   quiz->extrapasswords will contain an array of strings giving the remaining
298  *   passwords.
299  *
300  * @param object $quiz The quiz object.
301  * @param int $userid The userid.
302  * @return object $quiz The updated quiz object.
303  */
304 function quiz_update_effective_access($quiz, $userid) {
305     global $DB;
307     // Check for user override.
308     $override = $DB->get_record('quiz_overrides', array('quiz' => $quiz->id, 'userid' => $userid));
310     if (!$override) {
311         $override = new stdClass();
312         $override->timeopen = null;
313         $override->timeclose = null;
314         $override->timelimit = null;
315         $override->attempts = null;
316         $override->password = null;
317     }
319     // Check for group overrides.
320     $groupings = groups_get_user_groups($quiz->course, $userid);
322     if (!empty($groupings[0])) {
323         // Select all overrides that apply to the User's groups.
324         list($extra, $params) = $DB->get_in_or_equal(array_values($groupings[0]));
325         $sql = "SELECT * FROM {quiz_overrides}
326                 WHERE groupid $extra AND quiz = ?";
327         $params[] = $quiz->id;
328         $records = $DB->get_records_sql($sql, $params);
330         // Combine the overrides.
331         $opens = array();
332         $closes = array();
333         $limits = array();
334         $attempts = array();
335         $passwords = array();
337         foreach ($records as $gpoverride) {
338             if (isset($gpoverride->timeopen)) {
339                 $opens[] = $gpoverride->timeopen;
340             }
341             if (isset($gpoverride->timeclose)) {
342                 $closes[] = $gpoverride->timeclose;
343             }
344             if (isset($gpoverride->timelimit)) {
345                 $limits[] = $gpoverride->timelimit;
346             }
347             if (isset($gpoverride->attempts)) {
348                 $attempts[] = $gpoverride->attempts;
349             }
350             if (isset($gpoverride->password)) {
351                 $passwords[] = $gpoverride->password;
352             }
353         }
354         // If there is a user override for a setting, ignore the group override.
355         if (is_null($override->timeopen) && count($opens)) {
356             $override->timeopen = min($opens);
357         }
358         if (is_null($override->timeclose) && count($closes)) {
359             if (in_array(0, $closes)) {
360                 $override->timeclose = 0;
361             } else {
362                 $override->timeclose = max($closes);
363             }
364         }
365         if (is_null($override->timelimit) && count($limits)) {
366             if (in_array(0, $limits)) {
367                 $override->timelimit = 0;
368             } else {
369                 $override->timelimit = max($limits);
370             }
371         }
372         if (is_null($override->attempts) && count($attempts)) {
373             if (in_array(0, $attempts)) {
374                 $override->attempts = 0;
375             } else {
376                 $override->attempts = max($attempts);
377             }
378         }
379         if (is_null($override->password) && count($passwords)) {
380             $override->password = array_shift($passwords);
381             if (count($passwords)) {
382                 $override->extrapasswords = $passwords;
383             }
384         }
386     }
388     // Merge with quiz defaults.
389     $keys = array('timeopen', 'timeclose', 'timelimit', 'attempts', 'password', 'extrapasswords');
390     foreach ($keys as $key) {
391         if (isset($override->{$key})) {
392             $quiz->{$key} = $override->{$key};
393         }
394     }
396     return $quiz;
399 /**
400  * Delete all the attempts belonging to a quiz.
401  *
402  * @param object $quiz The quiz object.
403  */
404 function quiz_delete_all_attempts($quiz) {
405     global $CFG, $DB;
406     require_once($CFG->dirroot . '/mod/quiz/locallib.php');
407     question_engine::delete_questions_usage_by_activities(new qubaids_for_quiz($quiz->id));
408     $DB->delete_records('quiz_attempts', array('quiz' => $quiz->id));
409     $DB->delete_records('quiz_grades', array('quiz' => $quiz->id));
412 /**
413  * Delete all the attempts belonging to a user in a particular quiz.
414  *
415  * @param object $quiz The quiz object.
416  * @param object $user The user object.
417  */
418 function quiz_delete_user_attempts($quiz, $user) {
419     global $CFG, $DB;
420     require_once($CFG->dirroot . '/mod/quiz/locallib.php');
421     question_engine::delete_questions_usage_by_activities(new qubaids_for_quiz_user($quiz->get_quizid(), $user->id));
422     $params = [
423         'quiz' => $quiz->get_quizid(),
424         'userid' => $user->id,
425     ];
426     $DB->delete_records('quiz_attempts', $params);
427     $DB->delete_records('quiz_grades', $params);
430 /**
431  * Get the best current grade for a particular user in a quiz.
432  *
433  * @param object $quiz the quiz settings.
434  * @param int $userid the id of the user.
435  * @return float the user's current grade for this quiz, or null if this user does
436  * not have a grade on this quiz.
437  */
438 function quiz_get_best_grade($quiz, $userid) {
439     global $DB;
440     $grade = $DB->get_field('quiz_grades', 'grade',
441             array('quiz' => $quiz->id, 'userid' => $userid));
443     // Need to detect errors/no result, without catching 0 grades.
444     if ($grade === false) {
445         return null;
446     }
448     return $grade + 0; // Convert to number.
451 /**
452  * Is this a graded quiz? If this method returns true, you can assume that
453  * $quiz->grade and $quiz->sumgrades are non-zero (for example, if you want to
454  * divide by them).
455  *
456  * @param object $quiz a row from the quiz table.
457  * @return bool whether this is a graded quiz.
458  */
459 function quiz_has_grades($quiz) {
460     return $quiz->grade >= 0.000005 && $quiz->sumgrades >= 0.000005;
463 /**
464  * Does this quiz allow multiple tries?
465  *
466  * @return bool
467  */
468 function quiz_allows_multiple_tries($quiz) {
469     $bt = question_engine::get_behaviour_type($quiz->preferredbehaviour);
470     return $bt->allows_multiple_submitted_responses();
473 /**
474  * Return a small object with summary information about what a
475  * user has done with a given particular instance of this module
476  * Used for user activity reports.
477  * $return->time = the time they did it
478  * $return->info = a short text description
479  *
480  * @param object $course
481  * @param object $user
482  * @param object $mod
483  * @param object $quiz
484  * @return object|null
485  */
486 function quiz_user_outline($course, $user, $mod, $quiz) {
487     global $DB, $CFG;
488     require_once($CFG->libdir . '/gradelib.php');
489     $grades = grade_get_grades($course->id, 'mod', 'quiz', $quiz->id, $user->id);
491     if (empty($grades->items[0]->grades)) {
492         return null;
493     } else {
494         $grade = reset($grades->items[0]->grades);
495     }
497     $result = new stdClass();
498     // If the user can't see hidden grades, don't return that information.
499     $gitem = grade_item::fetch(array('id' => $grades->items[0]->id));
500     if (!$gitem->hidden || has_capability('moodle/grade:viewhidden', context_course::instance($course->id))) {
501         $result->info = get_string('grade') . ': ' . $grade->str_long_grade;
502     } else {
503         $result->info = get_string('grade') . ': ' . get_string('hidden', 'grades');
504     }
506     $result->time = grade_get_date_for_user_grade($grade, $user);
508     return $result;
511 /**
512  * Print a detailed representation of what a  user has done with
513  * a given particular instance of this module, for user activity reports.
514  *
515  * @param object $course
516  * @param object $user
517  * @param object $mod
518  * @param object $quiz
519  * @return bool
520  */
521 function quiz_user_complete($course, $user, $mod, $quiz) {
522     global $DB, $CFG, $OUTPUT;
523     require_once($CFG->libdir . '/gradelib.php');
524     require_once($CFG->dirroot . '/mod/quiz/locallib.php');
526     $grades = grade_get_grades($course->id, 'mod', 'quiz', $quiz->id, $user->id);
527     if (!empty($grades->items[0]->grades)) {
528         $grade = reset($grades->items[0]->grades);
529         // If the user can't see hidden grades, don't return that information.
530         $gitem = grade_item::fetch(array('id' => $grades->items[0]->id));
531         if (!$gitem->hidden || has_capability('moodle/grade:viewhidden', context_course::instance($course->id))) {
532             echo $OUTPUT->container(get_string('grade').': '.$grade->str_long_grade);
533             if ($grade->str_feedback) {
534                 echo $OUTPUT->container(get_string('feedback').': '.$grade->str_feedback);
535             }
536         } else {
537             echo $OUTPUT->container(get_string('grade') . ': ' . get_string('hidden', 'grades'));
538             if ($grade->str_feedback) {
539                 echo $OUTPUT->container(get_string('feedback').': '.get_string('hidden', 'grades'));
540             }
541         }
542     }
544     if ($attempts = $DB->get_records('quiz_attempts',
545             array('userid' => $user->id, 'quiz' => $quiz->id), 'attempt')) {
546         foreach ($attempts as $attempt) {
547             echo get_string('attempt', 'quiz', $attempt->attempt) . ': ';
548             if ($attempt->state != quiz_attempt::FINISHED) {
549                 echo quiz_attempt_state_name($attempt->state);
550             } else {
551                 if (!isset($gitem)) {
552                     if (!empty($grades->items[0]->grades)) {
553                         $gitem = grade_item::fetch(array('id' => $grades->items[0]->id));
554                     } else {
555                         $gitem = new stdClass();
556                         $gitem->hidden = true;
557                     }
558                 }
559                 if (!$gitem->hidden || has_capability('moodle/grade:viewhidden', context_course::instance($course->id))) {
560                     echo quiz_format_grade($quiz, $attempt->sumgrades) . '/' . quiz_format_grade($quiz, $quiz->sumgrades);
561                 } else {
562                     echo get_string('hidden', 'grades');
563                 }
564                 echo ' - '.userdate($attempt->timefinish).'<br />';
565             }
566         }
567     } else {
568         print_string('noattempts', 'quiz');
569     }
571     return true;
575 /**
576  * @param int|array $quizids A quiz ID, or an array of quiz IDs.
577  * @param int $userid the userid.
578  * @param string $status 'all', 'finished' or 'unfinished' to control
579  * @param bool $includepreviews
580  * @return an array of all the user's attempts at this quiz. Returns an empty
581  *      array if there are none.
582  */
583 function quiz_get_user_attempts($quizids, $userid, $status = 'finished', $includepreviews = false) {
584     global $DB, $CFG;
585     // TODO MDL-33071 it is very annoying to have to included all of locallib.php
586     // just to get the quiz_attempt::FINISHED constants, but I will try to sort
587     // that out properly for Moodle 2.4. For now, I will just do a quick fix for
588     // MDL-33048.
589     require_once($CFG->dirroot . '/mod/quiz/locallib.php');
591     $params = array();
592     switch ($status) {
593         case 'all':
594             $statuscondition = '';
595             break;
597         case 'finished':
598             $statuscondition = ' AND state IN (:state1, :state2)';
599             $params['state1'] = quiz_attempt::FINISHED;
600             $params['state2'] = quiz_attempt::ABANDONED;
601             break;
603         case 'unfinished':
604             $statuscondition = ' AND state IN (:state1, :state2)';
605             $params['state1'] = quiz_attempt::IN_PROGRESS;
606             $params['state2'] = quiz_attempt::OVERDUE;
607             break;
608     }
610     $quizids = (array) $quizids;
611     list($insql, $inparams) = $DB->get_in_or_equal($quizids, SQL_PARAMS_NAMED);
612     $params += $inparams;
613     $params['userid'] = $userid;
615     $previewclause = '';
616     if (!$includepreviews) {
617         $previewclause = ' AND preview = 0';
618     }
620     return $DB->get_records_select('quiz_attempts',
621             "quiz $insql AND userid = :userid" . $previewclause . $statuscondition,
622             $params, 'quiz, attempt ASC');
625 /**
626  * Return grade for given user or all users.
627  *
628  * @param int $quizid id of quiz
629  * @param int $userid optional user id, 0 means all users
630  * @return array array of grades, false if none. These are raw grades. They should
631  * be processed with quiz_format_grade for display.
632  */
633 function quiz_get_user_grades($quiz, $userid = 0) {
634     global $CFG, $DB;
636     $params = array($quiz->id);
637     $usertest = '';
638     if ($userid) {
639         $params[] = $userid;
640         $usertest = 'AND u.id = ?';
641     }
642     return $DB->get_records_sql("
643             SELECT
644                 u.id,
645                 u.id AS userid,
646                 qg.grade AS rawgrade,
647                 qg.timemodified AS dategraded,
648                 MAX(qa.timefinish) AS datesubmitted
650             FROM {user} u
651             JOIN {quiz_grades} qg ON u.id = qg.userid
652             JOIN {quiz_attempts} qa ON qa.quiz = qg.quiz AND qa.userid = u.id
654             WHERE qg.quiz = ?
655             $usertest
656             GROUP BY u.id, qg.grade, qg.timemodified", $params);
659 /**
660  * Round a grade to to the correct number of decimal places, and format it for display.
661  *
662  * @param object $quiz The quiz table row, only $quiz->decimalpoints is used.
663  * @param float $grade The grade to round.
664  * @return float
665  */
666 function quiz_format_grade($quiz, $grade) {
667     if (is_null($grade)) {
668         return get_string('notyetgraded', 'quiz');
669     }
670     return format_float($grade, $quiz->decimalpoints);
673 /**
674  * Determine the correct number of decimal places required to format a grade.
675  *
676  * @param object $quiz The quiz table row, only $quiz->decimalpoints is used.
677  * @return integer
678  */
679 function quiz_get_grade_format($quiz) {
680     if (empty($quiz->questiondecimalpoints)) {
681         $quiz->questiondecimalpoints = -1;
682     }
684     if ($quiz->questiondecimalpoints == -1) {
685         return $quiz->decimalpoints;
686     }
688     return $quiz->questiondecimalpoints;
691 /**
692  * Round a grade to the correct number of decimal places, and format it for display.
693  *
694  * @param object $quiz The quiz table row, only $quiz->decimalpoints is used.
695  * @param float $grade The grade to round.
696  * @return float
697  */
698 function quiz_format_question_grade($quiz, $grade) {
699     return format_float($grade, quiz_get_grade_format($quiz));
702 /**
703  * Update grades in central gradebook
704  *
705  * @category grade
706  * @param object $quiz the quiz settings.
707  * @param int $userid specific user only, 0 means all users.
708  * @param bool $nullifnone If a single user is specified and $nullifnone is true a grade item with a null rawgrade will be inserted
709  */
710 function quiz_update_grades($quiz, $userid = 0, $nullifnone = true) {
711     global $CFG, $DB;
712     require_once($CFG->libdir . '/gradelib.php');
714     if ($quiz->grade == 0) {
715         quiz_grade_item_update($quiz);
717     } else if ($grades = quiz_get_user_grades($quiz, $userid)) {
718         quiz_grade_item_update($quiz, $grades);
720     } else if ($userid && $nullifnone) {
721         $grade = new stdClass();
722         $grade->userid = $userid;
723         $grade->rawgrade = null;
724         quiz_grade_item_update($quiz, $grade);
726     } else {
727         quiz_grade_item_update($quiz);
728     }
731 /**
732  * Create or update the grade item for given quiz
733  *
734  * @category grade
735  * @param object $quiz object with extra cmidnumber
736  * @param mixed $grades optional array/object of grade(s); 'reset' means reset grades in gradebook
737  * @return int 0 if ok, error code otherwise
738  */
739 function quiz_grade_item_update($quiz, $grades = null) {
740     global $CFG, $OUTPUT;
741     require_once($CFG->dirroot . '/mod/quiz/locallib.php');
742     require_once($CFG->libdir . '/gradelib.php');
744     if (property_exists($quiz, 'cmidnumber')) { // May not be always present.
745         $params = array('itemname' => $quiz->name, 'idnumber' => $quiz->cmidnumber);
746     } else {
747         $params = array('itemname' => $quiz->name);
748     }
750     if ($quiz->grade > 0) {
751         $params['gradetype'] = GRADE_TYPE_VALUE;
752         $params['grademax']  = $quiz->grade;
753         $params['grademin']  = 0;
755     } else {
756         $params['gradetype'] = GRADE_TYPE_NONE;
757     }
759     // What this is trying to do:
760     // 1. If the quiz is set to not show grades while the quiz is still open,
761     //    and is set to show grades after the quiz is closed, then create the
762     //    grade_item with a show-after date that is the quiz close date.
763     // 2. If the quiz is set to not show grades at either of those times,
764     //    create the grade_item as hidden.
765     // 3. If the quiz is set to show grades, create the grade_item visible.
766     $openreviewoptions = mod_quiz_display_options::make_from_quiz($quiz,
767             mod_quiz_display_options::LATER_WHILE_OPEN);
768     $closedreviewoptions = mod_quiz_display_options::make_from_quiz($quiz,
769             mod_quiz_display_options::AFTER_CLOSE);
770     if ($openreviewoptions->marks < question_display_options::MARK_AND_MAX &&
771             $closedreviewoptions->marks < question_display_options::MARK_AND_MAX) {
772         $params['hidden'] = 1;
774     } else if ($openreviewoptions->marks < question_display_options::MARK_AND_MAX &&
775             $closedreviewoptions->marks >= question_display_options::MARK_AND_MAX) {
776         if ($quiz->timeclose) {
777             $params['hidden'] = $quiz->timeclose;
778         } else {
779             $params['hidden'] = 1;
780         }
782     } else {
783         // Either
784         // a) both open and closed enabled
785         // b) open enabled, closed disabled - we can not "hide after",
786         //    grades are kept visible even after closing.
787         $params['hidden'] = 0;
788     }
790     if (!$params['hidden']) {
791         // If the grade item is not hidden by the quiz logic, then we need to
792         // hide it if the quiz is hidden from students.
793         if (property_exists($quiz, 'visible')) {
794             // Saving the quiz form, and cm not yet updated in the database.
795             $params['hidden'] = !$quiz->visible;
796         } else {
797             $cm = get_coursemodule_from_instance('quiz', $quiz->id);
798             $params['hidden'] = !$cm->visible;
799         }
800     }
802     if ($grades  === 'reset') {
803         $params['reset'] = true;
804         $grades = null;
805     }
807     $gradebook_grades = grade_get_grades($quiz->course, 'mod', 'quiz', $quiz->id);
808     if (!empty($gradebook_grades->items)) {
809         $grade_item = $gradebook_grades->items[0];
810         if ($grade_item->locked) {
811             // NOTE: this is an extremely nasty hack! It is not a bug if this confirmation fails badly. --skodak.
812             $confirm_regrade = optional_param('confirm_regrade', 0, PARAM_INT);
813             if (!$confirm_regrade) {
814                 if (!AJAX_SCRIPT) {
815                     $message = get_string('gradeitemislocked', 'grades');
816                     $back_link = $CFG->wwwroot . '/mod/quiz/report.php?q=' . $quiz->id .
817                             '&amp;mode=overview';
818                     $regrade_link = qualified_me() . '&amp;confirm_regrade=1';
819                     echo $OUTPUT->box_start('generalbox', 'notice');
820                     echo '<p>'. $message .'</p>';
821                     echo $OUTPUT->container_start('buttons');
822                     echo $OUTPUT->single_button($regrade_link, get_string('regradeanyway', 'grades'));
823                     echo $OUTPUT->single_button($back_link,  get_string('cancel'));
824                     echo $OUTPUT->container_end();
825                     echo $OUTPUT->box_end();
826                 }
827                 return GRADE_UPDATE_ITEM_LOCKED;
828             }
829         }
830     }
832     return grade_update('mod/quiz', $quiz->course, 'mod', 'quiz', $quiz->id, 0, $grades, $params);
835 /**
836  * Delete grade item for given quiz
837  *
838  * @category grade
839  * @param object $quiz object
840  * @return object quiz
841  */
842 function quiz_grade_item_delete($quiz) {
843     global $CFG;
844     require_once($CFG->libdir . '/gradelib.php');
846     return grade_update('mod/quiz', $quiz->course, 'mod', 'quiz', $quiz->id, 0,
847             null, array('deleted' => 1));
850 /**
851  * This standard function will check all instances of this module
852  * and make sure there are up-to-date events created for each of them.
853  * If courseid = 0, then every quiz event in the site is checked, else
854  * only quiz events belonging to the course specified are checked.
855  * This function is used, in its new format, by restore_refresh_events()
856  *
857  * @param int $courseid
858  * @param int|stdClass $instance Quiz module instance or ID.
859  * @param int|stdClass $cm Course module object or ID (not used in this module).
860  * @return bool
861  */
862 function quiz_refresh_events($courseid = 0, $instance = null, $cm = null) {
863     global $DB;
865     // If we have instance information then we can just update the one event instead of updating all events.
866     if (isset($instance)) {
867         if (!is_object($instance)) {
868             $instance = $DB->get_record('quiz', array('id' => $instance), '*', MUST_EXIST);
869         }
870         quiz_update_events($instance);
871         return true;
872     }
874     if ($courseid == 0) {
875         if (!$quizzes = $DB->get_records('quiz')) {
876             return true;
877         }
878     } else {
879         if (!$quizzes = $DB->get_records('quiz', array('course' => $courseid))) {
880             return true;
881         }
882     }
884     foreach ($quizzes as $quiz) {
885         quiz_update_events($quiz);
886     }
888     return true;
891 /**
892  * Returns all quiz graded users since a given time for specified quiz
893  */
894 function quiz_get_recent_mod_activity(&$activities, &$index, $timestart,
895         $courseid, $cmid, $userid = 0, $groupid = 0) {
896     global $CFG, $USER, $DB;
897     require_once($CFG->dirroot . '/mod/quiz/locallib.php');
899     $course = get_course($courseid);
900     $modinfo = get_fast_modinfo($course);
902     $cm = $modinfo->cms[$cmid];
903     $quiz = $DB->get_record('quiz', array('id' => $cm->instance));
905     if ($userid) {
906         $userselect = "AND u.id = :userid";
907         $params['userid'] = $userid;
908     } else {
909         $userselect = '';
910     }
912     if ($groupid) {
913         $groupselect = 'AND gm.groupid = :groupid';
914         $groupjoin   = 'JOIN {groups_members} gm ON  gm.userid=u.id';
915         $params['groupid'] = $groupid;
916     } else {
917         $groupselect = '';
918         $groupjoin   = '';
919     }
921     $params['timestart'] = $timestart;
922     $params['quizid'] = $quiz->id;
924     $ufields = user_picture::fields('u', null, 'useridagain');
925     if (!$attempts = $DB->get_records_sql("
926               SELECT qa.*,
927                      {$ufields}
928                 FROM {quiz_attempts} qa
929                      JOIN {user} u ON u.id = qa.userid
930                      $groupjoin
931                WHERE qa.timefinish > :timestart
932                  AND qa.quiz = :quizid
933                  AND qa.preview = 0
934                      $userselect
935                      $groupselect
936             ORDER BY qa.timefinish ASC", $params)) {
937         return;
938     }
940     $context         = context_module::instance($cm->id);
941     $accessallgroups = has_capability('moodle/site:accessallgroups', $context);
942     $viewfullnames   = has_capability('moodle/site:viewfullnames', $context);
943     $grader          = has_capability('mod/quiz:viewreports', $context);
944     $groupmode       = groups_get_activity_groupmode($cm, $course);
946     $usersgroups = null;
947     $aname = format_string($cm->name, true);
948     foreach ($attempts as $attempt) {
949         if ($attempt->userid != $USER->id) {
950             if (!$grader) {
951                 // Grade permission required.
952                 continue;
953             }
955             if ($groupmode == SEPARATEGROUPS and !$accessallgroups) {
956                 $usersgroups = groups_get_all_groups($course->id,
957                         $attempt->userid, $cm->groupingid);
958                 $usersgroups = array_keys($usersgroups);
959                 if (!array_intersect($usersgroups, $modinfo->get_groups($cm->groupingid))) {
960                     continue;
961                 }
962             }
963         }
965         $options = quiz_get_review_options($quiz, $attempt, $context);
967         $tmpactivity = new stdClass();
969         $tmpactivity->type       = 'quiz';
970         $tmpactivity->cmid       = $cm->id;
971         $tmpactivity->name       = $aname;
972         $tmpactivity->sectionnum = $cm->sectionnum;
973         $tmpactivity->timestamp  = $attempt->timefinish;
975         $tmpactivity->content = new stdClass();
976         $tmpactivity->content->attemptid = $attempt->id;
977         $tmpactivity->content->attempt   = $attempt->attempt;
978         if (quiz_has_grades($quiz) && $options->marks >= question_display_options::MARK_AND_MAX) {
979             $tmpactivity->content->sumgrades = quiz_format_grade($quiz, $attempt->sumgrades);
980             $tmpactivity->content->maxgrade  = quiz_format_grade($quiz, $quiz->sumgrades);
981         } else {
982             $tmpactivity->content->sumgrades = null;
983             $tmpactivity->content->maxgrade  = null;
984         }
986         $tmpactivity->user = user_picture::unalias($attempt, null, 'useridagain');
987         $tmpactivity->user->fullname  = fullname($tmpactivity->user, $viewfullnames);
989         $activities[$index++] = $tmpactivity;
990     }
993 function quiz_print_recent_mod_activity($activity, $courseid, $detail, $modnames) {
994     global $CFG, $OUTPUT;
996     echo '<table border="0" cellpadding="3" cellspacing="0" class="forum-recent">';
998     echo '<tr><td class="userpicture" valign="top">';
999     echo $OUTPUT->user_picture($activity->user, array('courseid' => $courseid));
1000     echo '</td><td>';
1002     if ($detail) {
1003         $modname = $modnames[$activity->type];
1004         echo '<div class="title">';
1005         echo $OUTPUT->image_icon('icon', $modname, $activity->type);
1006         echo '<a href="' . $CFG->wwwroot . '/mod/quiz/view.php?id=' .
1007                 $activity->cmid . '">' . $activity->name . '</a>';
1008         echo '</div>';
1009     }
1011     echo '<div class="grade">';
1012     echo  get_string('attempt', 'quiz', $activity->content->attempt);
1013     if (isset($activity->content->maxgrade)) {
1014         $grades = $activity->content->sumgrades . ' / ' . $activity->content->maxgrade;
1015         echo ': (<a href="' . $CFG->wwwroot . '/mod/quiz/review.php?attempt=' .
1016                 $activity->content->attemptid . '">' . $grades . '</a>)';
1017     }
1018     echo '</div>';
1020     echo '<div class="user">';
1021     echo '<a href="' . $CFG->wwwroot . '/user/view.php?id=' . $activity->user->id .
1022             '&amp;course=' . $courseid . '">' . $activity->user->fullname .
1023             '</a> - ' . userdate($activity->timestamp);
1024     echo '</div>';
1026     echo '</td></tr></table>';
1028     return;
1031 /**
1032  * Pre-process the quiz options form data, making any necessary adjustments.
1033  * Called by add/update instance in this file.
1034  *
1035  * @param object $quiz The variables set on the form.
1036  */
1037 function quiz_process_options($quiz) {
1038     global $CFG;
1039     require_once($CFG->dirroot . '/mod/quiz/locallib.php');
1040     require_once($CFG->libdir . '/questionlib.php');
1042     $quiz->timemodified = time();
1044     // Quiz name.
1045     if (!empty($quiz->name)) {
1046         $quiz->name = trim($quiz->name);
1047     }
1049     // Password field - different in form to stop browsers that remember passwords
1050     // getting confused.
1051     $quiz->password = $quiz->quizpassword;
1052     unset($quiz->quizpassword);
1054     // Quiz feedback.
1055     if (isset($quiz->feedbacktext)) {
1056         // Clean up the boundary text.
1057         for ($i = 0; $i < count($quiz->feedbacktext); $i += 1) {
1058             if (empty($quiz->feedbacktext[$i]['text'])) {
1059                 $quiz->feedbacktext[$i]['text'] = '';
1060             } else {
1061                 $quiz->feedbacktext[$i]['text'] = trim($quiz->feedbacktext[$i]['text']);
1062             }
1063         }
1065         // Check the boundary value is a number or a percentage, and in range.
1066         $i = 0;
1067         while (!empty($quiz->feedbackboundaries[$i])) {
1068             $boundary = trim($quiz->feedbackboundaries[$i]);
1069             if (!is_numeric($boundary)) {
1070                 if (strlen($boundary) > 0 && $boundary[strlen($boundary) - 1] == '%') {
1071                     $boundary = trim(substr($boundary, 0, -1));
1072                     if (is_numeric($boundary)) {
1073                         $boundary = $boundary * $quiz->grade / 100.0;
1074                     } else {
1075                         return get_string('feedbackerrorboundaryformat', 'quiz', $i + 1);
1076                     }
1077                 }
1078             }
1079             if ($boundary <= 0 || $boundary >= $quiz->grade) {
1080                 return get_string('feedbackerrorboundaryoutofrange', 'quiz', $i + 1);
1081             }
1082             if ($i > 0 && $boundary >= $quiz->feedbackboundaries[$i - 1]) {
1083                 return get_string('feedbackerrororder', 'quiz', $i + 1);
1084             }
1085             $quiz->feedbackboundaries[$i] = $boundary;
1086             $i += 1;
1087         }
1088         $numboundaries = $i;
1090         // Check there is nothing in the remaining unused fields.
1091         if (!empty($quiz->feedbackboundaries)) {
1092             for ($i = $numboundaries; $i < count($quiz->feedbackboundaries); $i += 1) {
1093                 if (!empty($quiz->feedbackboundaries[$i]) &&
1094                         trim($quiz->feedbackboundaries[$i]) != '') {
1095                     return get_string('feedbackerrorjunkinboundary', 'quiz', $i + 1);
1096                 }
1097             }
1098         }
1099         for ($i = $numboundaries + 1; $i < count($quiz->feedbacktext); $i += 1) {
1100             if (!empty($quiz->feedbacktext[$i]['text']) &&
1101                     trim($quiz->feedbacktext[$i]['text']) != '') {
1102                 return get_string('feedbackerrorjunkinfeedback', 'quiz', $i + 1);
1103             }
1104         }
1105         // Needs to be bigger than $quiz->grade because of '<' test in quiz_feedback_for_grade().
1106         $quiz->feedbackboundaries[-1] = $quiz->grade + 1;
1107         $quiz->feedbackboundaries[$numboundaries] = 0;
1108         $quiz->feedbackboundarycount = $numboundaries;
1109     } else {
1110         $quiz->feedbackboundarycount = -1;
1111     }
1113     // Combing the individual settings into the review columns.
1114     $quiz->reviewattempt = quiz_review_option_form_to_db($quiz, 'attempt');
1115     $quiz->reviewcorrectness = quiz_review_option_form_to_db($quiz, 'correctness');
1116     $quiz->reviewmarks = quiz_review_option_form_to_db($quiz, 'marks');
1117     $quiz->reviewspecificfeedback = quiz_review_option_form_to_db($quiz, 'specificfeedback');
1118     $quiz->reviewgeneralfeedback = quiz_review_option_form_to_db($quiz, 'generalfeedback');
1119     $quiz->reviewrightanswer = quiz_review_option_form_to_db($quiz, 'rightanswer');
1120     $quiz->reviewoverallfeedback = quiz_review_option_form_to_db($quiz, 'overallfeedback');
1121     $quiz->reviewattempt |= mod_quiz_display_options::DURING;
1122     $quiz->reviewoverallfeedback &= ~mod_quiz_display_options::DURING;
1124     // Ensure that disabled checkboxes in completion settings are set to 0.
1125     if (empty($quiz->completionusegrade)) {
1126         $quiz->completionpass = 0;
1127     }
1128     if (empty($quiz->completionpass)) {
1129         $quiz->completionattemptsexhausted = 0;
1130     }
1131     if (empty($quiz->completionminattemptsenabled)) {
1132         $quiz->completionminattempts = 0;
1133     }
1136 /**
1137  * Helper function for {@link quiz_process_options()}.
1138  * @param object $fromform the sumbitted form date.
1139  * @param string $field one of the review option field names.
1140  */
1141 function quiz_review_option_form_to_db($fromform, $field) {
1142     static $times = array(
1143         'during' => mod_quiz_display_options::DURING,
1144         'immediately' => mod_quiz_display_options::IMMEDIATELY_AFTER,
1145         'open' => mod_quiz_display_options::LATER_WHILE_OPEN,
1146         'closed' => mod_quiz_display_options::AFTER_CLOSE,
1147     );
1149     $review = 0;
1150     foreach ($times as $whenname => $when) {
1151         $fieldname = $field . $whenname;
1152         if (isset($fromform->$fieldname)) {
1153             $review |= $when;
1154             unset($fromform->$fieldname);
1155         }
1156     }
1158     return $review;
1161 /**
1162  * This function is called at the end of quiz_add_instance
1163  * and quiz_update_instance, to do the common processing.
1164  *
1165  * @param object $quiz the quiz object.
1166  */
1167 function quiz_after_add_or_update($quiz) {
1168     global $DB;
1169     $cmid = $quiz->coursemodule;
1171     // We need to use context now, so we need to make sure all needed info is already in db.
1172     $DB->set_field('course_modules', 'instance', $quiz->id, array('id'=>$cmid));
1173     $context = context_module::instance($cmid);
1175     // Save the feedback.
1176     $DB->delete_records('quiz_feedback', array('quizid' => $quiz->id));
1178     for ($i = 0; $i <= $quiz->feedbackboundarycount; $i++) {
1179         $feedback = new stdClass();
1180         $feedback->quizid = $quiz->id;
1181         $feedback->feedbacktext = $quiz->feedbacktext[$i]['text'];
1182         $feedback->feedbacktextformat = $quiz->feedbacktext[$i]['format'];
1183         $feedback->mingrade = $quiz->feedbackboundaries[$i];
1184         $feedback->maxgrade = $quiz->feedbackboundaries[$i - 1];
1185         $feedback->id = $DB->insert_record('quiz_feedback', $feedback);
1186         $feedbacktext = file_save_draft_area_files((int)$quiz->feedbacktext[$i]['itemid'],
1187                 $context->id, 'mod_quiz', 'feedback', $feedback->id,
1188                 array('subdirs' => false, 'maxfiles' => -1, 'maxbytes' => 0),
1189                 $quiz->feedbacktext[$i]['text']);
1190         $DB->set_field('quiz_feedback', 'feedbacktext', $feedbacktext,
1191                 array('id' => $feedback->id));
1192     }
1194     // Store any settings belonging to the access rules.
1195     quiz_access_manager::save_settings($quiz);
1197     // Update the events relating to this quiz.
1198     quiz_update_events($quiz);
1199     $completionexpected = (!empty($quiz->completionexpected)) ? $quiz->completionexpected : null;
1200     \core_completion\api::update_completion_date_event($quiz->coursemodule, 'quiz', $quiz->id, $completionexpected);
1202     // Update related grade item.
1203     quiz_grade_item_update($quiz);
1206 /**
1207  * This function updates the events associated to the quiz.
1208  * If $override is non-zero, then it updates only the events
1209  * associated with the specified override.
1210  *
1211  * @uses QUIZ_MAX_EVENT_LENGTH
1212  * @param object $quiz the quiz object.
1213  * @param object optional $override limit to a specific override
1214  */
1215 function quiz_update_events($quiz, $override = null) {
1216     global $DB;
1218     // Load the old events relating to this quiz.
1219     $conds = array('modulename'=>'quiz',
1220                    'instance'=>$quiz->id);
1221     if (!empty($override)) {
1222         // Only load events for this override.
1223         if (isset($override->userid)) {
1224             $conds['userid'] = $override->userid;
1225         } else {
1226             $conds['groupid'] = $override->groupid;
1227         }
1228     }
1229     $oldevents = $DB->get_records('event', $conds, 'id ASC');
1231     // Now make a to-do list of all that needs to be updated.
1232     if (empty($override)) {
1233         // We are updating the primary settings for the quiz, so we need to add all the overrides.
1234         $overrides = $DB->get_records('quiz_overrides', array('quiz' => $quiz->id), 'id ASC');
1235         // It is necessary to add an empty stdClass to the beginning of the array as the $oldevents
1236         // list contains the original (non-override) event for the module. If this is not included
1237         // the logic below will end up updating the wrong row when we try to reconcile this $overrides
1238         // list against the $oldevents list.
1239         array_unshift($overrides, new stdClass());
1240     } else {
1241         // Just do the one override.
1242         $overrides = array($override);
1243     }
1245     // Get group override priorities.
1246     $grouppriorities = quiz_get_group_override_priorities($quiz->id);
1248     foreach ($overrides as $current) {
1249         $groupid   = isset($current->groupid)?  $current->groupid : 0;
1250         $userid    = isset($current->userid)? $current->userid : 0;
1251         $timeopen  = isset($current->timeopen)?  $current->timeopen : $quiz->timeopen;
1252         $timeclose = isset($current->timeclose)? $current->timeclose : $quiz->timeclose;
1254         // Only add open/close events for an override if they differ from the quiz default.
1255         $addopen  = empty($current->id) || !empty($current->timeopen);
1256         $addclose = empty($current->id) || !empty($current->timeclose);
1258         if (!empty($quiz->coursemodule)) {
1259             $cmid = $quiz->coursemodule;
1260         } else {
1261             $cmid = get_coursemodule_from_instance('quiz', $quiz->id, $quiz->course)->id;
1262         }
1264         $event = new stdClass();
1265         $event->type = !$timeclose ? CALENDAR_EVENT_TYPE_ACTION : CALENDAR_EVENT_TYPE_STANDARD;
1266         $event->description = format_module_intro('quiz', $quiz, $cmid, false);
1267         $event->format = FORMAT_HTML;
1268         // Events module won't show user events when the courseid is nonzero.
1269         $event->courseid    = ($userid) ? 0 : $quiz->course;
1270         $event->groupid     = $groupid;
1271         $event->userid      = $userid;
1272         $event->modulename  = 'quiz';
1273         $event->instance    = $quiz->id;
1274         $event->timestart   = $timeopen;
1275         $event->timeduration = max($timeclose - $timeopen, 0);
1276         $event->timesort    = $timeopen;
1277         $event->visible     = instance_is_visible('quiz', $quiz);
1278         $event->eventtype   = QUIZ_EVENT_TYPE_OPEN;
1279         $event->priority    = null;
1281         // Determine the event name and priority.
1282         if ($groupid) {
1283             // Group override event.
1284             $params = new stdClass();
1285             $params->quiz = $quiz->name;
1286             $params->group = groups_get_group_name($groupid);
1287             if ($params->group === false) {
1288                 // Group doesn't exist, just skip it.
1289                 continue;
1290             }
1291             $eventname = get_string('overridegroupeventname', 'quiz', $params);
1292             // Set group override priority.
1293             if ($grouppriorities !== null) {
1294                 $openpriorities = $grouppriorities['open'];
1295                 if (isset($openpriorities[$timeopen])) {
1296                     $event->priority = $openpriorities[$timeopen];
1297                 }
1298             }
1299         } else if ($userid) {
1300             // User override event.
1301             $params = new stdClass();
1302             $params->quiz = $quiz->name;
1303             $eventname = get_string('overrideusereventname', 'quiz', $params);
1304             // Set user override priority.
1305             $event->priority = CALENDAR_EVENT_USER_OVERRIDE_PRIORITY;
1306         } else {
1307             // The parent event.
1308             $eventname = $quiz->name;
1309         }
1311         if ($addopen or $addclose) {
1312             // Separate start and end events.
1313             $event->timeduration  = 0;
1314             if ($timeopen && $addopen) {
1315                 if ($oldevent = array_shift($oldevents)) {
1316                     $event->id = $oldevent->id;
1317                 } else {
1318                     unset($event->id);
1319                 }
1320                 $event->name = get_string('quizeventopens', 'quiz', $eventname);
1321                 // The method calendar_event::create will reuse a db record if the id field is set.
1322                 calendar_event::create($event, false);
1323             }
1324             if ($timeclose && $addclose) {
1325                 if ($oldevent = array_shift($oldevents)) {
1326                     $event->id = $oldevent->id;
1327                 } else {
1328                     unset($event->id);
1329                 }
1330                 $event->type      = CALENDAR_EVENT_TYPE_ACTION;
1331                 $event->name      = get_string('quizeventcloses', 'quiz', $eventname);
1332                 $event->timestart = $timeclose;
1333                 $event->timesort  = $timeclose;
1334                 $event->eventtype = QUIZ_EVENT_TYPE_CLOSE;
1335                 if ($groupid && $grouppriorities !== null) {
1336                     $closepriorities = $grouppriorities['close'];
1337                     if (isset($closepriorities[$timeclose])) {
1338                         $event->priority = $closepriorities[$timeclose];
1339                     }
1340                 }
1341                 calendar_event::create($event, false);
1342             }
1343         }
1344     }
1346     // Delete any leftover events.
1347     foreach ($oldevents as $badevent) {
1348         $badevent = calendar_event::load($badevent);
1349         $badevent->delete();
1350     }
1353 /**
1354  * Calculates the priorities of timeopen and timeclose values for group overrides for a quiz.
1355  *
1356  * @param int $quizid The quiz ID.
1357  * @return array|null Array of group override priorities for open and close times. Null if there are no group overrides.
1358  */
1359 function quiz_get_group_override_priorities($quizid) {
1360     global $DB;
1362     // Fetch group overrides.
1363     $where = 'quiz = :quiz AND groupid IS NOT NULL';
1364     $params = ['quiz' => $quizid];
1365     $overrides = $DB->get_records_select('quiz_overrides', $where, $params, '', 'id, timeopen, timeclose');
1366     if (!$overrides) {
1367         return null;
1368     }
1370     $grouptimeopen = [];
1371     $grouptimeclose = [];
1372     foreach ($overrides as $override) {
1373         if ($override->timeopen !== null && !in_array($override->timeopen, $grouptimeopen)) {
1374             $grouptimeopen[] = $override->timeopen;
1375         }
1376         if ($override->timeclose !== null && !in_array($override->timeclose, $grouptimeclose)) {
1377             $grouptimeclose[] = $override->timeclose;
1378         }
1379     }
1381     // Sort open times in ascending manner. The earlier open time gets higher priority.
1382     sort($grouptimeopen);
1383     // Set priorities.
1384     $opengrouppriorities = [];
1385     $openpriority = 1;
1386     foreach ($grouptimeopen as $timeopen) {
1387         $opengrouppriorities[$timeopen] = $openpriority++;
1388     }
1390     // Sort close times in descending manner. The later close time gets higher priority.
1391     rsort($grouptimeclose);
1392     // Set priorities.
1393     $closegrouppriorities = [];
1394     $closepriority = 1;
1395     foreach ($grouptimeclose as $timeclose) {
1396         $closegrouppriorities[$timeclose] = $closepriority++;
1397     }
1399     return [
1400         'open' => $opengrouppriorities,
1401         'close' => $closegrouppriorities
1402     ];
1405 /**
1406  * List the actions that correspond to a view of this module.
1407  * This is used by the participation report.
1408  *
1409  * Note: This is not used by new logging system. Event with
1410  *       crud = 'r' and edulevel = LEVEL_PARTICIPATING will
1411  *       be considered as view action.
1412  *
1413  * @return array
1414  */
1415 function quiz_get_view_actions() {
1416     return array('view', 'view all', 'report', 'review');
1419 /**
1420  * List the actions that correspond to a post of this module.
1421  * This is used by the participation report.
1422  *
1423  * Note: This is not used by new logging system. Event with
1424  *       crud = ('c' || 'u' || 'd') and edulevel = LEVEL_PARTICIPATING
1425  *       will be considered as post action.
1426  *
1427  * @return array
1428  */
1429 function quiz_get_post_actions() {
1430     return array('attempt', 'close attempt', 'preview', 'editquestions',
1431             'delete attempt', 'manualgrade');
1434 /**
1435  * @param array $questionids of question ids.
1436  * @return bool whether any of these questions are used by any instance of this module.
1437  */
1438 function quiz_questions_in_use($questionids) {
1439     global $DB, $CFG;
1440     require_once($CFG->libdir . '/questionlib.php');
1441     list($test, $params) = $DB->get_in_or_equal($questionids);
1442     return $DB->record_exists_select('quiz_slots',
1443             'questionid ' . $test, $params) || question_engine::questions_in_use(
1444             $questionids, new qubaid_join('{quiz_attempts} quiza',
1445             'quiza.uniqueid', 'quiza.preview = 0'));
1448 /**
1449  * Implementation of the function for printing the form elements that control
1450  * whether the course reset functionality affects the quiz.
1451  *
1452  * @param $mform the course reset form that is being built.
1453  */
1454 function quiz_reset_course_form_definition($mform) {
1455     $mform->addElement('header', 'quizheader', get_string('modulenameplural', 'quiz'));
1456     $mform->addElement('advcheckbox', 'reset_quiz_attempts',
1457             get_string('removeallquizattempts', 'quiz'));
1458     $mform->addElement('advcheckbox', 'reset_quiz_user_overrides',
1459             get_string('removealluseroverrides', 'quiz'));
1460     $mform->addElement('advcheckbox', 'reset_quiz_group_overrides',
1461             get_string('removeallgroupoverrides', 'quiz'));
1464 /**
1465  * Course reset form defaults.
1466  * @return array the defaults.
1467  */
1468 function quiz_reset_course_form_defaults($course) {
1469     return array('reset_quiz_attempts' => 1,
1470                  'reset_quiz_group_overrides' => 1,
1471                  'reset_quiz_user_overrides' => 1);
1474 /**
1475  * Removes all grades from gradebook
1476  *
1477  * @param int $courseid
1478  * @param string optional type
1479  */
1480 function quiz_reset_gradebook($courseid, $type='') {
1481     global $CFG, $DB;
1483     $quizzes = $DB->get_records_sql("
1484             SELECT q.*, cm.idnumber as cmidnumber, q.course as courseid
1485             FROM {modules} m
1486             JOIN {course_modules} cm ON m.id = cm.module
1487             JOIN {quiz} q ON cm.instance = q.id
1488             WHERE m.name = 'quiz' AND cm.course = ?", array($courseid));
1490     foreach ($quizzes as $quiz) {
1491         quiz_grade_item_update($quiz, 'reset');
1492     }
1495 /**
1496  * Actual implementation of the reset course functionality, delete all the
1497  * quiz attempts for course $data->courseid, if $data->reset_quiz_attempts is
1498  * set and true.
1499  *
1500  * Also, move the quiz open and close dates, if the course start date is changing.
1501  *
1502  * @param object $data the data submitted from the reset course.
1503  * @return array status array
1504  */
1505 function quiz_reset_userdata($data) {
1506     global $CFG, $DB;
1507     require_once($CFG->libdir . '/questionlib.php');
1509     $componentstr = get_string('modulenameplural', 'quiz');
1510     $status = array();
1512     // Delete attempts.
1513     if (!empty($data->reset_quiz_attempts)) {
1514         question_engine::delete_questions_usage_by_activities(new qubaid_join(
1515                 '{quiz_attempts} quiza JOIN {quiz} quiz ON quiza.quiz = quiz.id',
1516                 'quiza.uniqueid', 'quiz.course = :quizcourseid',
1517                 array('quizcourseid' => $data->courseid)));
1519         $DB->delete_records_select('quiz_attempts',
1520                 'quiz IN (SELECT id FROM {quiz} WHERE course = ?)', array($data->courseid));
1521         $status[] = array(
1522             'component' => $componentstr,
1523             'item' => get_string('attemptsdeleted', 'quiz'),
1524             'error' => false);
1526         // Remove all grades from gradebook.
1527         $DB->delete_records_select('quiz_grades',
1528                 'quiz IN (SELECT id FROM {quiz} WHERE course = ?)', array($data->courseid));
1529         if (empty($data->reset_gradebook_grades)) {
1530             quiz_reset_gradebook($data->courseid);
1531         }
1532         $status[] = array(
1533             'component' => $componentstr,
1534             'item' => get_string('gradesdeleted', 'quiz'),
1535             'error' => false);
1536     }
1538     // Remove user overrides.
1539     if (!empty($data->reset_quiz_user_overrides)) {
1540         $DB->delete_records_select('quiz_overrides',
1541                 'quiz IN (SELECT id FROM {quiz} WHERE course = ?) AND userid IS NOT NULL', array($data->courseid));
1542         $status[] = array(
1543             'component' => $componentstr,
1544             'item' => get_string('useroverridesdeleted', 'quiz'),
1545             'error' => false);
1546     }
1547     // Remove group overrides.
1548     if (!empty($data->reset_quiz_group_overrides)) {
1549         $DB->delete_records_select('quiz_overrides',
1550                 'quiz IN (SELECT id FROM {quiz} WHERE course = ?) AND groupid IS NOT NULL', array($data->courseid));
1551         $status[] = array(
1552             'component' => $componentstr,
1553             'item' => get_string('groupoverridesdeleted', 'quiz'),
1554             'error' => false);
1555     }
1557     // Updating dates - shift may be negative too.
1558     if ($data->timeshift) {
1559         $DB->execute("UPDATE {quiz_overrides}
1560                          SET timeopen = timeopen + ?
1561                        WHERE quiz IN (SELECT id FROM {quiz} WHERE course = ?)
1562                          AND timeopen <> 0", array($data->timeshift, $data->courseid));
1563         $DB->execute("UPDATE {quiz_overrides}
1564                          SET timeclose = timeclose + ?
1565                        WHERE quiz IN (SELECT id FROM {quiz} WHERE course = ?)
1566                          AND timeclose <> 0", array($data->timeshift, $data->courseid));
1568         // Any changes to the list of dates that needs to be rolled should be same during course restore and course reset.
1569         // See MDL-9367.
1570         shift_course_mod_dates('quiz', array('timeopen', 'timeclose'),
1571                 $data->timeshift, $data->courseid);
1573         $status[] = array(
1574             'component' => $componentstr,
1575             'item' => get_string('openclosedatesupdated', 'quiz'),
1576             'error' => false);
1577     }
1579     return $status;
1582 /**
1583  * @deprecated since Moodle 3.3, when the block_course_overview block was removed.
1584  */
1585 function quiz_print_overview() {
1586     throw new coding_exception('quiz_print_overview() can not be used any more and is obsolete.');
1589 /**
1590  * Return a textual summary of the number of attempts that have been made at a particular quiz,
1591  * returns '' if no attempts have been made yet, unless $returnzero is passed as true.
1592  *
1593  * @param object $quiz the quiz object. Only $quiz->id is used at the moment.
1594  * @param object $cm the cm object. Only $cm->course, $cm->groupmode and
1595  *      $cm->groupingid fields are used at the moment.
1596  * @param bool $returnzero if false (default), when no attempts have been
1597  *      made '' is returned instead of 'Attempts: 0'.
1598  * @param int $currentgroup if there is a concept of current group where this method is being called
1599  *         (e.g. a report) pass it in here. Default 0 which means no current group.
1600  * @return string a string like "Attempts: 123", "Attemtps 123 (45 from your groups)" or
1601  *          "Attemtps 123 (45 from this group)".
1602  */
1603 function quiz_num_attempt_summary($quiz, $cm, $returnzero = false, $currentgroup = 0) {
1604     global $DB, $USER;
1605     $numattempts = $DB->count_records('quiz_attempts', array('quiz'=> $quiz->id, 'preview'=>0));
1606     if ($numattempts || $returnzero) {
1607         if (groups_get_activity_groupmode($cm)) {
1608             $a = new stdClass();
1609             $a->total = $numattempts;
1610             if ($currentgroup) {
1611                 $a->group = $DB->count_records_sql('SELECT COUNT(DISTINCT qa.id) FROM ' .
1612                         '{quiz_attempts} qa JOIN ' .
1613                         '{groups_members} gm ON qa.userid = gm.userid ' .
1614                         'WHERE quiz = ? AND preview = 0 AND groupid = ?',
1615                         array($quiz->id, $currentgroup));
1616                 return get_string('attemptsnumthisgroup', 'quiz', $a);
1617             } else if ($groups = groups_get_all_groups($cm->course, $USER->id, $cm->groupingid)) {
1618                 list($usql, $params) = $DB->get_in_or_equal(array_keys($groups));
1619                 $a->group = $DB->count_records_sql('SELECT COUNT(DISTINCT qa.id) FROM ' .
1620                         '{quiz_attempts} qa JOIN ' .
1621                         '{groups_members} gm ON qa.userid = gm.userid ' .
1622                         'WHERE quiz = ? AND preview = 0 AND ' .
1623                         "groupid $usql", array_merge(array($quiz->id), $params));
1624                 return get_string('attemptsnumyourgroups', 'quiz', $a);
1625             }
1626         }
1627         return get_string('attemptsnum', 'quiz', $numattempts);
1628     }
1629     return '';
1632 /**
1633  * Returns the same as {@link quiz_num_attempt_summary()} but wrapped in a link
1634  * to the quiz reports.
1635  *
1636  * @param object $quiz the quiz object. Only $quiz->id is used at the moment.
1637  * @param object $cm the cm object. Only $cm->course, $cm->groupmode and
1638  *      $cm->groupingid fields are used at the moment.
1639  * @param object $context the quiz context.
1640  * @param bool $returnzero if false (default), when no attempts have been made
1641  *      '' is returned instead of 'Attempts: 0'.
1642  * @param int $currentgroup if there is a concept of current group where this method is being called
1643  *         (e.g. a report) pass it in here. Default 0 which means no current group.
1644  * @return string HTML fragment for the link.
1645  */
1646 function quiz_attempt_summary_link_to_reports($quiz, $cm, $context, $returnzero = false,
1647         $currentgroup = 0) {
1648     global $CFG;
1649     $summary = quiz_num_attempt_summary($quiz, $cm, $returnzero, $currentgroup);
1650     if (!$summary) {
1651         return '';
1652     }
1654     require_once($CFG->dirroot . '/mod/quiz/report/reportlib.php');
1655     $url = new moodle_url('/mod/quiz/report.php', array(
1656             'id' => $cm->id, 'mode' => quiz_report_default_report($context)));
1657     return html_writer::link($url, $summary);
1660 /**
1661  * @param string $feature FEATURE_xx constant for requested feature
1662  * @return bool True if quiz supports feature
1663  */
1664 function quiz_supports($feature) {
1665     switch($feature) {
1666         case FEATURE_GROUPS:                    return true;
1667         case FEATURE_GROUPINGS:                 return true;
1668         case FEATURE_MOD_INTRO:                 return true;
1669         case FEATURE_COMPLETION_TRACKS_VIEWS:   return true;
1670         case FEATURE_COMPLETION_HAS_RULES:      return true;
1671         case FEATURE_GRADE_HAS_GRADE:           return true;
1672         case FEATURE_GRADE_OUTCOMES:            return true;
1673         case FEATURE_BACKUP_MOODLE2:            return true;
1674         case FEATURE_SHOW_DESCRIPTION:          return true;
1675         case FEATURE_CONTROLS_GRADE_VISIBILITY: return true;
1676         case FEATURE_USES_QUESTIONS:            return true;
1678         default: return null;
1679     }
1682 /**
1683  * @return array all other caps used in module
1684  */
1685 function quiz_get_extra_capabilities() {
1686     global $CFG;
1687     require_once($CFG->libdir . '/questionlib.php');
1688     return question_get_all_capabilities();
1691 /**
1692  * This function extends the settings navigation block for the site.
1693  *
1694  * It is safe to rely on PAGE here as we will only ever be within the module
1695  * context when this is called
1696  *
1697  * @param settings_navigation $settings
1698  * @param navigation_node $quiznode
1699  * @return void
1700  */
1701 function quiz_extend_settings_navigation($settings, $quiznode) {
1702     global $PAGE, $CFG;
1704     // Require {@link questionlib.php}
1705     // Included here as we only ever want to include this file if we really need to.
1706     require_once($CFG->libdir . '/questionlib.php');
1708     // We want to add these new nodes after the Edit settings node, and before the
1709     // Locally assigned roles node. Of course, both of those are controlled by capabilities.
1710     $keys = $quiznode->get_children_key_list();
1711     $beforekey = null;
1712     $i = array_search('modedit', $keys);
1713     if ($i === false and array_key_exists(0, $keys)) {
1714         $beforekey = $keys[0];
1715     } else if (array_key_exists($i + 1, $keys)) {
1716         $beforekey = $keys[$i + 1];
1717     }
1719     if (has_capability('mod/quiz:manageoverrides', $PAGE->cm->context)) {
1720         $url = new moodle_url('/mod/quiz/overrides.php', array('cmid'=>$PAGE->cm->id));
1721         $node = navigation_node::create(get_string('groupoverrides', 'quiz'),
1722                 new moodle_url($url, array('mode'=>'group')),
1723                 navigation_node::TYPE_SETTING, null, 'mod_quiz_groupoverrides');
1724         $quiznode->add_node($node, $beforekey);
1726         $node = navigation_node::create(get_string('useroverrides', 'quiz'),
1727                 new moodle_url($url, array('mode'=>'user')),
1728                 navigation_node::TYPE_SETTING, null, 'mod_quiz_useroverrides');
1729         $quiznode->add_node($node, $beforekey);
1730     }
1732     if (has_capability('mod/quiz:manage', $PAGE->cm->context)) {
1733         $node = navigation_node::create(get_string('editquiz', 'quiz'),
1734                 new moodle_url('/mod/quiz/edit.php', array('cmid'=>$PAGE->cm->id)),
1735                 navigation_node::TYPE_SETTING, null, 'mod_quiz_edit',
1736                 new pix_icon('t/edit', ''));
1737         $quiznode->add_node($node, $beforekey);
1738     }
1740     if (has_capability('mod/quiz:preview', $PAGE->cm->context)) {
1741         $url = new moodle_url('/mod/quiz/startattempt.php',
1742                 array('cmid'=>$PAGE->cm->id, 'sesskey'=>sesskey()));
1743         $node = navigation_node::create(get_string('preview', 'quiz'), $url,
1744                 navigation_node::TYPE_SETTING, null, 'mod_quiz_preview',
1745                 new pix_icon('i/preview', ''));
1746         $quiznode->add_node($node, $beforekey);
1747     }
1749     if (has_any_capability(array('mod/quiz:viewreports', 'mod/quiz:grade'), $PAGE->cm->context)) {
1750         require_once($CFG->dirroot . '/mod/quiz/report/reportlib.php');
1751         $reportlist = quiz_report_list($PAGE->cm->context);
1753         $url = new moodle_url('/mod/quiz/report.php',
1754                 array('id' => $PAGE->cm->id, 'mode' => reset($reportlist)));
1755         $reportnode = $quiznode->add_node(navigation_node::create(get_string('results', 'quiz'), $url,
1756                 navigation_node::TYPE_SETTING,
1757                 null, null, new pix_icon('i/report', '')), $beforekey);
1759         foreach ($reportlist as $report) {
1760             $url = new moodle_url('/mod/quiz/report.php',
1761                     array('id' => $PAGE->cm->id, 'mode' => $report));
1762             $reportnode->add_node(navigation_node::create(get_string($report, 'quiz_'.$report), $url,
1763                     navigation_node::TYPE_SETTING,
1764                     null, 'quiz_report_' . $report, new pix_icon('i/item', '')));
1765         }
1766     }
1768     question_extend_settings_navigation($quiznode, $PAGE->cm->context)->trim_if_empty();
1771 /**
1772  * Serves the quiz files.
1773  *
1774  * @package  mod_quiz
1775  * @category files
1776  * @param stdClass $course course object
1777  * @param stdClass $cm course module object
1778  * @param stdClass $context context object
1779  * @param string $filearea file area
1780  * @param array $args extra arguments
1781  * @param bool $forcedownload whether or not force download
1782  * @param array $options additional options affecting the file serving
1783  * @return bool false if file not found, does not return if found - justsend the file
1784  */
1785 function quiz_pluginfile($course, $cm, $context, $filearea, $args, $forcedownload, array $options=array()) {
1786     global $CFG, $DB;
1788     if ($context->contextlevel != CONTEXT_MODULE) {
1789         return false;
1790     }
1792     require_login($course, false, $cm);
1794     if (!$quiz = $DB->get_record('quiz', array('id'=>$cm->instance))) {
1795         return false;
1796     }
1798     // The 'intro' area is served by pluginfile.php.
1799     $fileareas = array('feedback');
1800     if (!in_array($filearea, $fileareas)) {
1801         return false;
1802     }
1804     $feedbackid = (int)array_shift($args);
1805     if (!$feedback = $DB->get_record('quiz_feedback', array('id'=>$feedbackid))) {
1806         return false;
1807     }
1809     $fs = get_file_storage();
1810     $relativepath = implode('/', $args);
1811     $fullpath = "/$context->id/mod_quiz/$filearea/$feedbackid/$relativepath";
1812     if (!$file = $fs->get_file_by_hash(sha1($fullpath)) or $file->is_directory()) {
1813         return false;
1814     }
1815     send_stored_file($file, 0, 0, true, $options);
1818 /**
1819  * Called via pluginfile.php -> question_pluginfile to serve files belonging to
1820  * a question in a question_attempt when that attempt is a quiz attempt.
1821  *
1822  * @package  mod_quiz
1823  * @category files
1824  * @param stdClass $course course settings object
1825  * @param stdClass $context context object
1826  * @param string $component the name of the component we are serving files for.
1827  * @param string $filearea the name of the file area.
1828  * @param int $qubaid the attempt usage id.
1829  * @param int $slot the id of a question in this quiz attempt.
1830  * @param array $args the remaining bits of the file path.
1831  * @param bool $forcedownload whether the user must be forced to download the file.
1832  * @param array $options additional options affecting the file serving
1833  * @return bool false if file not found, does not return if found - justsend the file
1834  */
1835 function quiz_question_pluginfile($course, $context, $component,
1836         $filearea, $qubaid, $slot, $args, $forcedownload, array $options=array()) {
1837     global $CFG;
1838     require_once($CFG->dirroot . '/mod/quiz/locallib.php');
1840     $attemptobj = quiz_attempt::create_from_usage_id($qubaid);
1841     require_login($attemptobj->get_course(), false, $attemptobj->get_cm());
1843     if ($attemptobj->is_own_attempt() && !$attemptobj->is_finished()) {
1844         // In the middle of an attempt.
1845         if (!$attemptobj->is_preview_user()) {
1846             $attemptobj->require_capability('mod/quiz:attempt');
1847         }
1848         $isreviewing = false;
1850     } else {
1851         // Reviewing an attempt.
1852         $attemptobj->check_review_capability();
1853         $isreviewing = true;
1854     }
1856     if (!$attemptobj->check_file_access($slot, $isreviewing, $context->id,
1857             $component, $filearea, $args, $forcedownload)) {
1858         send_file_not_found();
1859     }
1861     $fs = get_file_storage();
1862     $relativepath = implode('/', $args);
1863     $fullpath = "/$context->id/$component/$filearea/$relativepath";
1864     if (!$file = $fs->get_file_by_hash(sha1($fullpath)) or $file->is_directory()) {
1865         send_file_not_found();
1866     }
1868     send_stored_file($file, 0, 0, $forcedownload, $options);
1871 /**
1872  * Return a list of page types
1873  * @param string $pagetype current page type
1874  * @param stdClass $parentcontext Block's parent context
1875  * @param stdClass $currentcontext Current context of block
1876  */
1877 function quiz_page_type_list($pagetype, $parentcontext, $currentcontext) {
1878     $module_pagetype = array(
1879         'mod-quiz-*'       => get_string('page-mod-quiz-x', 'quiz'),
1880         'mod-quiz-view'    => get_string('page-mod-quiz-view', 'quiz'),
1881         'mod-quiz-attempt' => get_string('page-mod-quiz-attempt', 'quiz'),
1882         'mod-quiz-summary' => get_string('page-mod-quiz-summary', 'quiz'),
1883         'mod-quiz-review'  => get_string('page-mod-quiz-review', 'quiz'),
1884         'mod-quiz-edit'    => get_string('page-mod-quiz-edit', 'quiz'),
1885         'mod-quiz-report'  => get_string('page-mod-quiz-report', 'quiz'),
1886     );
1887     return $module_pagetype;
1890 /**
1891  * @return the options for quiz navigation.
1892  */
1893 function quiz_get_navigation_options() {
1894     return array(
1895         QUIZ_NAVMETHOD_FREE => get_string('navmethod_free', 'quiz'),
1896         QUIZ_NAVMETHOD_SEQ  => get_string('navmethod_seq', 'quiz')
1897     );
1900 /**
1901  * Internal function used in quiz_get_completion_state. Check passing grade (or no attempts left) requirement for completion.
1902  *
1903  * @param object $course
1904  * @param object $cm
1905  * @param int $userid
1906  * @param object $quiz
1907  * @return bool True if the passing grade (or no attempts left) requirement is disabled or met.
1908  * @throws coding_exception
1909  */
1910 function quiz_completion_check_passing_grade_or_all_attempts($course, $cm, $userid, $quiz) {
1911     global $CFG;
1913     if (!$quiz->completionpass) {
1914         return true;
1915     }
1917     // Check for passing grade.
1918     require_once($CFG->libdir . '/gradelib.php');
1919     $item = grade_item::fetch(array('courseid' => $course->id, 'itemtype' => 'mod',
1920         'itemmodule' => 'quiz', 'iteminstance' => $cm->instance, 'outcomeid' => null));
1921     if ($item) {
1922         $grades = grade_grade::fetch_users_grades($item, array($userid), false);
1923         if (!empty($grades[$userid]) && $grades[$userid]->is_passed($item)) {
1924             return true;
1925         }
1926     }
1928     // If a passing grade is required and exhausting all available attempts is not accepted for completion,
1929     // then this quiz is not complete.
1930     if (!$quiz->completionattemptsexhausted) {
1931         return false;
1932     }
1934     // Check if all attempts are used up.
1935     $attempts = quiz_get_user_attempts($quiz->id, $userid, 'finished', true);
1936     if (!$attempts) {
1937         return false;
1938     }
1939     $lastfinishedattempt = end($attempts);
1940     $context = context_module::instance($cm->id);
1941     $quizobj = quiz::create($quiz->id, $userid);
1942     $accessmanager = new quiz_access_manager($quizobj, time(),
1943         has_capability('mod/quiz:ignoretimelimits', $context, $userid, false));
1945     return $accessmanager->is_finished(count($attempts), $lastfinishedattempt);
1948 /**
1949  * Internal function used in quiz_get_completion_state. Check minimum attempts requirement for completion.
1950  *
1951  * @param int $userid
1952  * @param object $quiz
1953  * @return bool True if minimum attempts requirement is disabled or met.
1954  * @throws coding_exception
1955  */
1956 function quiz_completion_check_min_attempts($userid, $quiz) {
1957     global $DB;
1959     if (empty($quiz->completionminattempts)) {
1960         return true;
1961     }
1963     // Check if the user has done enough attempts.
1964     $attempts = quiz_get_user_attempts($quiz->id, $userid, 'finished', true);
1965     return $quiz->completionminattempts <= count($attempts);
1968 /**
1969  * Obtains the automatic completion state for this quiz on any conditions
1970  * in quiz settings, such as if all attempts are used or a certain grade is achieved.
1971  *
1972  * @param object $course Course
1973  * @param object $cm Course-module
1974  * @param int $userid User ID
1975  * @param bool $type Type of comparison (or/and; can be used as return value if no conditions)
1976  * @return bool True if completed, false if not. (If no conditions, then return
1977  *   value depends on comparison type)
1978  */
1979 function quiz_get_completion_state($course, $cm, $userid, $type) {
1980     global $DB;
1982     $quiz = $DB->get_record('quiz', array('id' => $cm->instance), '*', MUST_EXIST);
1983     if (!$quiz->completionattemptsexhausted && !$quiz->completionpass && !$quiz->completionminattempts) {
1984         return $type;
1985     }
1987     if (!quiz_completion_check_passing_grade_or_all_attempts($course, $cm, $userid, $quiz)) {
1988         return false;
1989     }
1991     if (!quiz_completion_check_min_attempts($userid, $quiz)) {
1992         return false;
1993     }
1995     return true;
1998 /**
1999  * Check if the module has any update that affects the current user since a given time.
2000  *
2001  * @param  cm_info $cm course module data
2002  * @param  int $from the time to check updates from
2003  * @param  array $filter  if we need to check only specific updates
2004  * @return stdClass an object with the different type of areas indicating if they were updated or not
2005  * @since Moodle 3.2
2006  */
2007 function quiz_check_updates_since(cm_info $cm, $from, $filter = array()) {
2008     global $DB, $USER, $CFG;
2009     require_once($CFG->dirroot . '/mod/quiz/locallib.php');
2011     $updates = course_check_module_updates_since($cm, $from, array(), $filter);
2013     // Check if questions were updated.
2014     $updates->questions = (object) array('updated' => false);
2015     $quizobj = quiz::create($cm->instance, $USER->id);
2016     $quizobj->preload_questions();
2017     $quizobj->load_questions();
2018     $questionids = array_keys($quizobj->get_questions());
2019     if (!empty($questionids)) {
2020         list($questionsql, $params) = $DB->get_in_or_equal($questionids, SQL_PARAMS_NAMED);
2021         $select = 'id ' . $questionsql . ' AND (timemodified > :time1 OR timecreated > :time2)';
2022         $params['time1'] = $from;
2023         $params['time2'] = $from;
2024         $questions = $DB->get_records_select('question', $select, $params, '', 'id');
2025         if (!empty($questions)) {
2026             $updates->questions->updated = true;
2027             $updates->questions->itemids = array_keys($questions);
2028         }
2029     }
2031     // Check for new attempts or grades.
2032     $updates->attempts = (object) array('updated' => false);
2033     $updates->grades = (object) array('updated' => false);
2034     $select = 'quiz = ? AND userid = ? AND timemodified > ?';
2035     $params = array($cm->instance, $USER->id, $from);
2037     $attempts = $DB->get_records_select('quiz_attempts', $select, $params, '', 'id');
2038     if (!empty($attempts)) {
2039         $updates->attempts->updated = true;
2040         $updates->attempts->itemids = array_keys($attempts);
2041     }
2042     $grades = $DB->get_records_select('quiz_grades', $select, $params, '', 'id');
2043     if (!empty($grades)) {
2044         $updates->grades->updated = true;
2045         $updates->grades->itemids = array_keys($grades);
2046     }
2048     // Now, teachers should see other students updates.
2049     if (has_capability('mod/quiz:viewreports', $cm->context)) {
2050         $select = 'quiz = ? AND timemodified > ?';
2051         $params = array($cm->instance, $from);
2053         if (groups_get_activity_groupmode($cm) == SEPARATEGROUPS) {
2054             $groupusers = array_keys(groups_get_activity_shared_group_members($cm));
2055             if (empty($groupusers)) {
2056                 return $updates;
2057             }
2058             list($insql, $inparams) = $DB->get_in_or_equal($groupusers);
2059             $select .= ' AND userid ' . $insql;
2060             $params = array_merge($params, $inparams);
2061         }
2063         $updates->userattempts = (object) array('updated' => false);
2064         $attempts = $DB->get_records_select('quiz_attempts', $select, $params, '', 'id');
2065         if (!empty($attempts)) {
2066             $updates->userattempts->updated = true;
2067             $updates->userattempts->itemids = array_keys($attempts);
2068         }
2070         $updates->usergrades = (object) array('updated' => false);
2071         $grades = $DB->get_records_select('quiz_grades', $select, $params, '', 'id');
2072         if (!empty($grades)) {
2073             $updates->usergrades->updated = true;
2074             $updates->usergrades->itemids = array_keys($grades);
2075         }
2076     }
2077     return $updates;
2080 /**
2081  * Get icon mapping for font-awesome.
2082  */
2083 function mod_quiz_get_fontawesome_icon_map() {
2084     return [
2085         'mod_quiz:navflagged' => 'fa-flag',
2086     ];
2089 /**
2090  * This function receives a calendar event and returns the action associated with it, or null if there is none.
2091  *
2092  * This is used by block_myoverview in order to display the event appropriately. If null is returned then the event
2093  * is not displayed on the block.
2094  *
2095  * @param calendar_event $event
2096  * @param \core_calendar\action_factory $factory
2097  * @param int $userid User id to use for all capability checks, etc. Set to 0 for current user (default).
2098  * @return \core_calendar\local\event\entities\action_interface|null
2099  */
2100 function mod_quiz_core_calendar_provide_event_action(calendar_event $event,
2101                                                      \core_calendar\action_factory $factory,
2102                                                      int $userid = 0) {
2103     global $CFG, $USER;
2105     require_once($CFG->dirroot . '/mod/quiz/locallib.php');
2107     if (empty($userid)) {
2108         $userid = $USER->id;
2109     }
2111     $cm = get_fast_modinfo($event->courseid, $userid)->instances['quiz'][$event->instance];
2112     $quizobj = quiz::create($cm->instance, $userid);
2113     $quiz = $quizobj->get_quiz();
2115     // Check they have capabilities allowing them to view the quiz.
2116     if (!has_any_capability(['mod/quiz:reviewmyattempts', 'mod/quiz:attempt'], $quizobj->get_context(), $userid)) {
2117         return null;
2118     }
2120     $completion = new \completion_info($cm->get_course());
2122     $completiondata = $completion->get_data($cm, false, $userid);
2124     if ($completiondata->completionstate != COMPLETION_INCOMPLETE) {
2125         return null;
2126     }
2128     quiz_update_effective_access($quiz, $userid);
2130     // Check if quiz is closed, if so don't display it.
2131     if (!empty($quiz->timeclose) && $quiz->timeclose <= time()) {
2132         return null;
2133     }
2135     if (!$quizobj->is_participant($userid)) {
2136         // If the user is not a participant then they have
2137         // no action to take. This will filter out the events for teachers.
2138         return null;
2139     }
2141     $attempts = quiz_get_user_attempts($quizobj->get_quizid(), $userid);
2142     if (!empty($attempts)) {
2143         // The student's last attempt is finished.
2144         return null;
2145     }
2147     $name = get_string('attemptquiznow', 'quiz');
2148     $url = new \moodle_url('/mod/quiz/view.php', [
2149         'id' => $cm->id
2150     ]);
2151     $itemcount = 1;
2152     $actionable = true;
2154     // Check if the quiz is not currently actionable.
2155     if (!empty($quiz->timeopen) && $quiz->timeopen > time()) {
2156         $actionable = false;
2157     }
2159     return $factory->create_instance(
2160         $name,
2161         $url,
2162         $itemcount,
2163         $actionable
2164     );
2167 /**
2168  * Add a get_coursemodule_info function in case any quiz type wants to add 'extra' information
2169  * for the course (see resource).
2170  *
2171  * Given a course_module object, this function returns any "extra" information that may be needed
2172  * when printing this activity in a course listing.  See get_array_of_activities() in course/lib.php.
2173  *
2174  * @param stdClass $coursemodule The coursemodule object (record).
2175  * @return cached_cm_info An object on information that the courses
2176  *                        will know about (most noticeably, an icon).
2177  */
2178 function quiz_get_coursemodule_info($coursemodule) {
2179     global $DB;
2181     $dbparams = ['id' => $coursemodule->instance];
2182     $fields = 'id, name, intro, introformat, completionattemptsexhausted, completionpass';
2183     if (!$quiz = $DB->get_record('quiz', $dbparams, $fields)) {
2184         return false;
2185     }
2187     $result = new cached_cm_info();
2188     $result->name = $quiz->name;
2190     if ($coursemodule->showdescription) {
2191         // Convert intro to html. Do not filter cached version, filters run at display time.
2192         $result->content = format_module_intro('quiz', $quiz, $coursemodule->id, false);
2193     }
2195     // Populate the custom completion rules as key => value pairs, but only if the completion mode is 'automatic'.
2196     if ($coursemodule->completion == COMPLETION_TRACKING_AUTOMATIC) {
2197         $result->customdata['customcompletionrules']['completionattemptsexhausted'] = $quiz->completionattemptsexhausted;
2198         $result->customdata['customcompletionrules']['completionpass'] = $quiz->completionpass;
2199     }
2201     return $result;
2204 /**
2205  * Callback which returns human-readable strings describing the active completion custom rules for the module instance.
2206  *
2207  * @param cm_info|stdClass $cm object with fields ->completion and ->customdata['customcompletionrules']
2208  * @return array $descriptions the array of descriptions for the custom rules.
2209  */
2210 function mod_quiz_get_completion_active_rule_descriptions($cm) {
2211     // Values will be present in cm_info, and we assume these are up to date.
2212     if (empty($cm->customdata['customcompletionrules'])
2213         || $cm->completion != COMPLETION_TRACKING_AUTOMATIC) {
2214         return [];
2215     }
2217     $descriptions = [];
2218     foreach ($cm->customdata['customcompletionrules'] as $key => $val) {
2219         switch ($key) {
2220             case 'completionattemptsexhausted':
2221                 if (!empty($val)) {
2222                     $descriptions[] = get_string('completionattemptsexhausteddesc', 'quiz');
2223                 }
2224                 break;
2225             case 'completionpass':
2226                 if (!empty($val)) {
2227                     $descriptions[] = get_string('completionpassdesc', 'quiz', format_time($val));
2228                 }
2229                 break;
2230             default:
2231                 break;
2232         }
2233     }
2234     return $descriptions;
2237 /**
2238  * Returns the min and max values for the timestart property of a quiz
2239  * activity event.
2240  *
2241  * The min and max values will be the timeopen and timeclose properties
2242  * of the quiz, respectively, if they are set.
2243  *
2244  * If either value isn't set then null will be returned instead to
2245  * indicate that there is no cutoff for that value.
2246  *
2247  * If the vent has no valid timestart range then [false, false] will
2248  * be returned. This is the case for overriden events.
2249  *
2250  * A minimum and maximum cutoff return value will look like:
2251  * [
2252  *     [1505704373, 'The date must be after this date'],
2253  *     [1506741172, 'The date must be before this date']
2254  * ]
2255  *
2256  * @throws \moodle_exception
2257  * @param \calendar_event $event The calendar event to get the time range for
2258  * @param stdClass $quiz The module instance to get the range from
2259  * @return array
2260  */
2261 function mod_quiz_core_calendar_get_valid_event_timestart_range(\calendar_event $event, \stdClass $quiz) {
2262     global $CFG, $DB;
2263     require_once($CFG->dirroot . '/mod/quiz/locallib.php');
2265     // Overrides do not have a valid timestart range.
2266     if (quiz_is_overriden_calendar_event($event)) {
2267         return [false, false];
2268     }
2270     $mindate = null;
2271     $maxdate = null;
2273     if ($event->eventtype == QUIZ_EVENT_TYPE_OPEN) {
2274         if (!empty($quiz->timeclose)) {
2275             $maxdate = [
2276                 $quiz->timeclose,
2277                 get_string('openafterclose', 'quiz')
2278             ];
2279         }
2280     } else if ($event->eventtype == QUIZ_EVENT_TYPE_CLOSE) {
2281         if (!empty($quiz->timeopen)) {
2282             $mindate = [
2283                 $quiz->timeopen,
2284                 get_string('closebeforeopen', 'quiz')
2285             ];
2286         }
2287     }
2289     return [$mindate, $maxdate];
2292 /**
2293  * This function will update the quiz module according to the
2294  * event that has been modified.
2295  *
2296  * It will set the timeopen or timeclose value of the quiz instance
2297  * according to the type of event provided.
2298  *
2299  * @throws \moodle_exception
2300  * @param \calendar_event $event A quiz activity calendar event
2301  * @param \stdClass $quiz A quiz activity instance
2302  */
2303 function mod_quiz_core_calendar_event_timestart_updated(\calendar_event $event, \stdClass $quiz) {
2304     global $CFG, $DB;
2305     require_once($CFG->dirroot . '/mod/quiz/locallib.php');
2307     if (!in_array($event->eventtype, [QUIZ_EVENT_TYPE_OPEN, QUIZ_EVENT_TYPE_CLOSE])) {
2308         // This isn't an event that we care about so we can ignore it.
2309         return;
2310     }
2312     $courseid = $event->courseid;
2313     $modulename = $event->modulename;
2314     $instanceid = $event->instance;
2315     $modified = false;
2316     $closedatechanged = false;
2318     // Something weird going on. The event is for a different module so
2319     // we should ignore it.
2320     if ($modulename != 'quiz') {
2321         return;
2322     }
2324     if ($quiz->id != $instanceid) {
2325         // The provided quiz instance doesn't match the event so
2326         // there is nothing to do here.
2327         return;
2328     }
2330     // We don't update the activity if it's an override event that has
2331     // been modified.
2332     if (quiz_is_overriden_calendar_event($event)) {
2333         return;
2334     }
2336     $coursemodule = get_fast_modinfo($courseid)->instances[$modulename][$instanceid];
2337     $context = context_module::instance($coursemodule->id);
2339     // The user does not have the capability to modify this activity.
2340     if (!has_capability('moodle/course:manageactivities', $context)) {
2341         return;
2342     }
2344     if ($event->eventtype == QUIZ_EVENT_TYPE_OPEN) {
2345         // If the event is for the quiz activity opening then we should
2346         // set the start time of the quiz activity to be the new start
2347         // time of the event.
2348         if ($quiz->timeopen != $event->timestart) {
2349             $quiz->timeopen = $event->timestart;
2350             $modified = true;
2351         }
2352     } else if ($event->eventtype == QUIZ_EVENT_TYPE_CLOSE) {
2353         // If the event is for the quiz activity closing then we should
2354         // set the end time of the quiz activity to be the new start
2355         // time of the event.
2356         if ($quiz->timeclose != $event->timestart) {
2357             $quiz->timeclose = $event->timestart;
2358             $modified = true;
2359             $closedatechanged = true;
2360         }
2361     }
2363     if ($modified) {
2364         $quiz->timemodified = time();
2365         $DB->update_record('quiz', $quiz);
2367         if ($closedatechanged) {
2368             quiz_update_open_attempts(array('quizid' => $quiz->id));
2369         }
2371         // Delete any previous preview attempts.
2372         quiz_delete_previews($quiz);
2373         quiz_update_events($quiz);
2374         $event = \core\event\course_module_updated::create_from_cm($coursemodule, $context);
2375         $event->trigger();
2376     }
2379 /**
2380  * Generates the question bank in a fragment output. This allows
2381  * the question bank to be displayed in a modal.
2382  *
2383  * The only expected argument provided in the $args array is
2384  * 'querystring'. The value should be the list of parameters
2385  * URL encoded and used to build the question bank page.
2386  *
2387  * The individual list of parameters expected can be found in
2388  * question_build_edit_resources.
2389  *
2390  * @param array $args The fragment arguments.
2391  * @return string The rendered mform fragment.
2392  */
2393 function mod_quiz_output_fragment_quiz_question_bank($args) {
2394     global $CFG, $DB, $PAGE;
2395     require_once($CFG->dirroot . '/mod/quiz/locallib.php');
2396     require_once($CFG->dirroot . '/question/editlib.php');
2398     $querystring = preg_replace('/^\?/', '', $args['querystring']);
2399     $params = [];
2400     parse_str($querystring, $params);
2402     // Build the required resources. The $params are all cleaned as
2403     // part of this process.
2404     list($thispageurl, $contexts, $cmid, $cm, $quiz, $pagevars) =
2405             question_build_edit_resources('editq', '/mod/quiz/edit.php', $params);
2407     // Get the course object and related bits.
2408     $course = $DB->get_record('course', array('id' => $quiz->course), '*', MUST_EXIST);
2409     require_capability('mod/quiz:manage', $contexts->lowest());
2411     // Create quiz question bank view.
2412     $questionbank = new mod_quiz\question\bank\custom_view($contexts, $thispageurl, $course, $cm, $quiz);
2413     $questionbank->set_quiz_has_attempts(quiz_has_attempts($quiz->id));
2415     // Output.
2416     $renderer = $PAGE->get_renderer('mod_quiz', 'edit');
2417     return $renderer->question_bank_contents($questionbank, $pagevars);
2420 /**
2421  * Generates the add random question in a fragment output. This allows the
2422  * form to be rendered in javascript, for example inside a modal.
2423  *
2424  * The required arguments as keys in the $args array are:
2425  *      cat {string} The category and category context ids comma separated.
2426  *      addonpage {int} The page id to add this question to.
2427  *      returnurl {string} URL to return to after form submission.
2428  *      cmid {int} The course module id the questions are being added to.
2429  *
2430  * @param array $args The fragment arguments.
2431  * @return string The rendered mform fragment.
2432  */
2433 function mod_quiz_output_fragment_add_random_question_form($args) {
2434     global $CFG;
2435     require_once($CFG->dirroot . '/mod/quiz/addrandomform.php');
2437     $contexts = new \question_edit_contexts($args['context']);
2438     $formoptions = [
2439         'contexts' => $contexts,
2440         'cat' => $args['cat']
2441     ];
2442     $formdata = [
2443         'category' => $args['cat'],
2444         'addonpage' => $args['addonpage'],
2445         'returnurl' => $args['returnurl'],
2446         'cmid' => $args['cmid']
2447     ];
2449     $form = new quiz_add_random_form(
2450         new \moodle_url('/mod/quiz/addrandom.php'),
2451         $formoptions,
2452         'post',
2453         '',
2454         null,
2455         true,
2456         $formdata
2457     );
2458     $form->set_data($formdata);
2460     return $form->render();