diff --git a/admin/tool/recyclebin/tests/course_bin_test.php b/admin/tool/recyclebin/tests/course_bin_test.php index 821ceedf749..10a2427c98d 100644 --- a/admin/tool/recyclebin/tests/course_bin_test.php +++ b/admin/tool/recyclebin/tests/course_bin_test.php @@ -16,6 +16,8 @@ namespace tool_recyclebin; +use mod_quiz\quiz_attempt; + /** * Recycle bin course tests. * @@ -237,7 +239,7 @@ class course_bin_test extends \advanced_testcase { $attempts = quiz_get_user_attempts($cm->instance, $student->id); $this->assertEquals(1, count($attempts)); $attempt = array_pop($attempts); - $attemptobj = \quiz_attempt::create($attempt->id); + $attemptobj = quiz_attempt::create($attempt->id); $this->assertEquals($student->id, $attemptobj->get_userid()); $this->assertEquals(true, $attemptobj->is_finished()); } @@ -307,10 +309,10 @@ class course_bin_test extends \advanced_testcase { $attempt = quiz_create_attempt($quizobj, 1, false, $timenow, false, $student->id); quiz_start_new_attempt($quizobj, $quba, $attempt, 1, $timenow); quiz_attempt_save_started($quizobj, $quba, $attempt); - $attemptobj = \quiz_attempt::create($attempt->id); + $attemptobj = quiz_attempt::create($attempt->id); $tosubmit = array(1 => array('answer' => '0')); $attemptobj->process_submitted_actions($timenow, false, $tosubmit); - $attemptobj = \quiz_attempt::create($attempt->id); + $attemptobj = quiz_attempt::create($attempt->id); $attemptobj->process_finish($timenow, false); } } diff --git a/backup/moodle2/tests/restore_stepslib_date_test.php b/backup/moodle2/tests/restore_stepslib_date_test.php index 8ff878c55d3..009f57d3626 100644 --- a/backup/moodle2/tests/restore_stepslib_date_test.php +++ b/backup/moodle2/tests/restore_stepslib_date_test.php @@ -16,6 +16,8 @@ namespace core_backup; +use mod_quiz\quiz_attempt; + defined('MOODLE_INTERNAL') || die(); global $CFG; @@ -393,7 +395,7 @@ class restore_stepslib_date_test extends \restore_date_testcase { quiz_attempt_save_started($quizobj, $quba, $attempt); // Process some responses from the student. - $attemptobj = \quiz_attempt::create($attempt->id); + $attemptobj = quiz_attempt::create($attempt->id); $prefix1 = $quba->get_field_prefix(1); $prefix2 = $quba->get_field_prefix(2); @@ -404,7 +406,7 @@ class restore_stepslib_date_test extends \restore_date_testcase { $attemptobj->process_submitted_actions($timenow, false, $tosubmit); // Finish the attempt. - $attemptobj = \quiz_attempt::create($attempt->id); + $attemptobj = quiz_attempt::create($attempt->id); $attemptobj->process_finish($timenow, false); $questionattemptstepdates = []; diff --git a/mod/quiz/accessrule/seb/rule.php b/mod/quiz/accessrule/seb/rule.php index 27cc12cfe5b..efd8eef516d 100644 --- a/mod/quiz/accessrule/seb/rule.php +++ b/mod/quiz/accessrule/seb/rule.php @@ -15,6 +15,7 @@ // along with Moodle. If not, see . use mod_quiz\local\access_rule_base; +use mod_quiz\quiz_attempt; use quizaccess_seb\access_manager; use quizaccess_seb\quiz_settings; use quizaccess_seb\settings_provider; diff --git a/mod/quiz/accessrule/seb/tests/test_helper_trait.php b/mod/quiz/accessrule/seb/tests/test_helper_trait.php index b4ee1137734..564f916bd5f 100644 --- a/mod/quiz/accessrule/seb/tests/test_helper_trait.php +++ b/mod/quiz/accessrule/seb/tests/test_helper_trait.php @@ -24,6 +24,7 @@ */ use mod_quiz\local\access_rule_base; +use mod_quiz\quiz_attempt; use quizaccess_seb\access_manager; use quizaccess_seb\settings_provider; @@ -201,7 +202,7 @@ trait quizaccess_seb_test_helper_trait { quiz_attempt_save_started($quizobj, $quba, $attempt); // Answer the questions. - $attemptobj = \quiz_attempt::create($attempt->id); + $attemptobj = quiz_attempt::create($attempt->id); $tosubmit = [ 1 => ['answer' => 'frog'], @@ -211,7 +212,7 @@ trait quizaccess_seb_test_helper_trait { $attemptobj->process_submitted_actions($starttime, false, $tosubmit); // Finish the attempt. - $attemptobj = \quiz_attempt::create($attempt->id); + $attemptobj = quiz_attempt::create($attempt->id); $attemptobj->process_finish($starttime, false); $this->setUser(); diff --git a/mod/quiz/attempt.php b/mod/quiz/attempt.php index 9cd4e0e286c..163b8277ca4 100644 --- a/mod/quiz/attempt.php +++ b/mod/quiz/attempt.php @@ -24,6 +24,7 @@ use mod_quiz\output\navigation_panel_attempt; use mod_quiz\output\renderer; +use mod_quiz\quiz_attempt; require_once(__DIR__ . '/../../config.php'); require_once($CFG->dirroot . '/mod/quiz/locallib.php'); diff --git a/mod/quiz/attemptlib.php b/mod/quiz/attemptlib.php index 9e9ae61f595..a5c61f52f04 100644 --- a/mod/quiz/attemptlib.php +++ b/mod/quiz/attemptlib.php @@ -33,6 +33,7 @@ use mod_quiz\output\navigation_panel_base; use mod_quiz\output\renderer; use mod_quiz\question\bank\qbank_helper; use mod_quiz\question\display_options; +use mod_quiz\quiz_attempt; /** * A class encapsulating a quiz and the questions it contains, and making the @@ -111,7 +112,7 @@ class quiz { } /** - * Create a {@link quiz_attempt} for an attempt at this quiz. + * Create a {@see quiz_attempt} for an attempt at this quiz. * * @param object $attemptdata row from the quiz_attempts table. * @return quiz_attempt the new quiz_attempt object. @@ -553,2160 +554,3 @@ class quiz { return $questiontypes; } } - - -/** - * This class extends the quiz class to hold data about the state of a particular attempt, - * in addition to the data about the quiz. - * - * @copyright 2008 Tim Hunt - * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later - * @since Moodle 2.0 - */ -class quiz_attempt { - - /** @var string to identify the in progress state. */ - const IN_PROGRESS = 'inprogress'; - /** @var string to identify the overdue state. */ - const OVERDUE = 'overdue'; - /** @var string to identify the finished state. */ - const FINISHED = 'finished'; - /** @var string to identify the abandoned state. */ - const ABANDONED = 'abandoned'; - - /** @var int maximum number of slots in the quiz for the review page to default to show all. */ - const MAX_SLOTS_FOR_DEFAULT_REVIEW_SHOW_ALL = 50; - - /** @var quiz object containing the quiz settings. */ - protected $quizobj; - - /** @var stdClass the quiz_attempts row. */ - protected $attempt; - - /** @var question_usage_by_activity the question usage for this quiz attempt. */ - protected $quba; - - /** - * @var array of slot information. These objects contain ->slot (int), - * ->requireprevious (bool), ->questionids (int) the original question for random questions, - * ->firstinsection (bool), ->section (stdClass from $this->sections). - * This does not contain page - get that from {@link get_question_page()} - - * or maxmark - get that from $this->quba. - */ - protected $slots; - - /** @var array of quiz_sections rows, with a ->lastslot field added. */ - protected $sections; - - /** @var array page no => array of slot numbers on the page in order. */ - protected $pagelayout; - - /** @var array slot => displayed question number for this slot. (E.g. 1, 2, 3 or 'i'.) */ - protected $questionnumbers; - - /** @var array slot => page number for this slot. */ - protected $questionpages; - - /** @var display_options cache for the appropriate review options. */ - protected $reviewoptions = null; - - // Constructor ============================================================= - /** - * Constructor assuming we already have the necessary data loaded. - * - * @param object $attempt the row of the quiz_attempts table. - * @param object $quiz the quiz object for this attempt and user. - * @param object $cm the course_module object for this quiz. - * @param object $course the row from the course table for the course we belong to. - * @param bool $loadquestions (optional) if true, the default, load all the details - * of the state of each question. Else just set up the basic details of the attempt. - */ - public function __construct($attempt, $quiz, $cm, $course, $loadquestions = true) { - $this->attempt = $attempt; - $this->quizobj = new quiz($quiz, $cm, $course); - - if ($loadquestions) { - $this->load_questions(); - } - } - - /** - * Used by {create()} and {create_from_usage_id()}. - * - * @param array $conditions passed to $DB->get_record('quiz_attempts', $conditions). - * @return quiz_attempt the desired instance of this class. - */ - protected static function create_helper($conditions) { - global $DB; - - $attempt = $DB->get_record('quiz_attempts', $conditions, '*', MUST_EXIST); - $quiz = access_manager::load_quiz_and_settings($attempt->quiz); - $course = $DB->get_record('course', array('id' => $quiz->course), '*', MUST_EXIST); - $cm = get_coursemodule_from_instance('quiz', $quiz->id, $course->id, false, MUST_EXIST); - - // Update quiz with override information. - $quiz = quiz_update_effective_access($quiz, $attempt->userid); - - return new quiz_attempt($attempt, $quiz, $cm, $course); - } - - /** - * Static function to create a new quiz_attempt object given an attemptid. - * - * @param int $attemptid the attempt id. - * @return quiz_attempt the new quiz_attempt object - */ - public static function create($attemptid) { - return self::create_helper(array('id' => $attemptid)); - } - - /** - * Static function to create a new quiz_attempt object given a usage id. - * - * @param int $usageid the attempt usage id. - * @return quiz_attempt the new quiz_attempt object - */ - public static function create_from_usage_id($usageid) { - return self::create_helper(array('uniqueid' => $usageid)); - } - - /** - * @param string $state one of the state constants like IN_PROGRESS. - * @return string the human-readable state name. - */ - public static function state_name($state) { - return quiz_attempt_state_name($state); - } - - /** - * This method can be called later if the object was constructed with $loadqusetions = false. - */ - public function load_questions() { - global $DB; - - if (isset($this->quba)) { - throw new coding_exception('This quiz attempt has already had the questions loaded.'); - } - - $this->quba = question_engine::load_questions_usage_by_activity($this->attempt->uniqueid); - $this->slots = $DB->get_records('quiz_slots', - ['quizid' => $this->get_quizid()], 'slot', 'slot, id, requireprevious, displaynumber'); - $this->sections = array_values($DB->get_records('quiz_sections', - ['quizid' => $this->get_quizid()], 'firstslot')); - - $this->link_sections_and_slots(); - $this->determine_layout(); - $this->number_questions(); - } - - /** - * Preload all attempt step users to show in Response history. - * - * @throws dml_exception - */ - public function preload_all_attempt_step_users(): void { - $this->quba->preload_all_step_users(); - } - - /** - * Let each slot know which section it is part of. - */ - protected function link_sections_and_slots() { - foreach ($this->sections as $i => $section) { - if (isset($this->sections[$i + 1])) { - $section->lastslot = $this->sections[$i + 1]->firstslot - 1; - } else { - $section->lastslot = count($this->slots); - } - for ($slot = $section->firstslot; $slot <= $section->lastslot; $slot += 1) { - $this->slots[$slot]->section = $section; - } - } - } - - /** - * Parse attempt->layout to populate the other arrays the represent the layout. - */ - protected function determine_layout() { - $this->pagelayout = array(); - - // Break up the layout string into pages. - $pagelayouts = explode(',0', $this->attempt->layout); - - // Strip off any empty last page (normally there is one). - if (end($pagelayouts) == '') { - array_pop($pagelayouts); - } - - // File the ids into the arrays. - // Tracking which is the first slot in each section in this attempt is - // trickier than you might guess, since the slots in this section - // may be shuffled, so $section->firstslot (the lowest numbered slot in - // the section) may not be the first one. - $unseensections = $this->sections; - $this->pagelayout = array(); - foreach ($pagelayouts as $page => $pagelayout) { - $pagelayout = trim($pagelayout, ','); - if ($pagelayout == '') { - continue; - } - $this->pagelayout[$page] = explode(',', $pagelayout); - foreach ($this->pagelayout[$page] as $slot) { - $sectionkey = array_search($this->slots[$slot]->section, $unseensections); - if ($sectionkey !== false) { - $this->slots[$slot]->firstinsection = true; - unset($unseensections[$sectionkey]); - } else { - $this->slots[$slot]->firstinsection = false; - } - } - } - } - - /** - * Work out the number to display for each question/slot. - */ - protected function number_questions() { - $number = 1; - foreach ($this->pagelayout as $page => $slots) { - foreach ($slots as $slot) { - if ($length = $this->is_real_question($slot)) { - // Whether question numbering is customised or is numeric and automatically incremented. - if (!empty($this->slots[$slot]->displaynumber) && !is_null($this->slots[$slot]->displaynumber)) { - $this->questionnumbers[$slot] = $this->slots[$slot]->displaynumber; - } else { - $this->questionnumbers[$slot] = $number; - } - $number += $length; - } else { - $this->questionnumbers[$slot] = get_string('infoshort', 'quiz'); - } - $this->questionpages[$slot] = $page; - } - } - } - - /** - * If the given page number is out of range (before the first page, or after - * the last page, chnage it to be within range). - * - * @param int $page the requested page number. - * @return int a safe page number to use. - */ - public function force_page_number_into_range($page) { - return min(max($page, 0), count($this->pagelayout) - 1); - } - - // Simple getters ========================================================== - public function get_quiz() { - return $this->quizobj->get_quiz(); - } - - public function get_quizobj() { - return $this->quizobj; - } - - /** @return int the course id. */ - public function get_courseid() { - return $this->quizobj->get_courseid(); - } - - /** - * Get the course settings object. - * - * @return stdClass the course settings object. - */ - public function get_course() { - return $this->quizobj->get_course(); - } - - /** @return int the quiz id. */ - public function get_quizid() { - return $this->quizobj->get_quizid(); - } - - /** @return string the name of this quiz. */ - public function get_quiz_name() { - return $this->quizobj->get_quiz_name(); - } - - /** @return int the quiz navigation method. */ - public function get_navigation_method() { - return $this->quizobj->get_navigation_method(); - } - - /** @return object the course_module object. */ - public function get_cm() { - return $this->quizobj->get_cm(); - } - - /** - * Get the course-module id. - * - * @return int the course_module id. - */ - public function get_cmid() { - return $this->quizobj->get_cmid(); - } - - /** - * @return bool whether the current user is someone who previews the quiz, - * rather than attempting it. - */ - public function is_preview_user() { - return $this->quizobj->is_preview_user(); - } - - /** @return int the number of attempts allowed at this quiz (0 = infinite). */ - public function get_num_attempts_allowed() { - return $this->quizobj->get_num_attempts_allowed(); - } - - /** @return int number fo pages in this quiz. */ - public function get_num_pages() { - return count($this->pagelayout); - } - - /** - * @param int $timenow the current time as a unix timestamp. - * @return access_manager and instance of the access_manager class - * for this quiz at this time. - */ - public function get_access_manager($timenow) { - return $this->quizobj->get_access_manager($timenow); - } - - /** @return int the attempt id. */ - public function get_attemptid() { - return $this->attempt->id; - } - - /** @return int the attempt unique id. */ - public function get_uniqueid() { - return $this->attempt->uniqueid; - } - - /** @return object the row from the quiz_attempts table. */ - public function get_attempt() { - return $this->attempt; - } - - /** @return int the number of this attemp (is it this user's first, second, ... attempt). */ - public function get_attempt_number() { - return $this->attempt->attempt; - } - - /** @return string one of the quiz_attempt::IN_PROGRESS, FINISHED, OVERDUE or ABANDONED constants. */ - public function get_state() { - return $this->attempt->state; - } - - /** @return int the id of the user this attempt belongs to. */ - public function get_userid() { - return $this->attempt->userid; - } - - /** @return int the current page of the attempt. */ - public function get_currentpage() { - return $this->attempt->currentpage; - } - - public function get_sum_marks() { - return $this->attempt->sumgrades; - } - - /** - * @return bool whether this attempt has been finished (true) or is still - * in progress (false). Be warned that this is not just state == self::FINISHED, - * it also includes self::ABANDONED. - */ - public function is_finished() { - return $this->attempt->state == self::FINISHED || $this->attempt->state == self::ABANDONED; - } - - /** @return bool whether this attempt is a preview attempt. */ - public function is_preview() { - return $this->attempt->preview; - } - - /** - * Is this someone dealing with their own attempt or preview? - * - * @return bool true => own attempt/preview. false => reviewing someone else's. - */ - public function is_own_attempt() { - global $USER; - return $this->attempt->userid == $USER->id; - } - - /** - * @return bool whether this attempt is a preview belonging to the current user. - */ - public function is_own_preview() { - return $this->is_own_attempt() && - $this->is_preview_user() && $this->attempt->preview; - } - - /** - * Is the current user allowed to review this attempt. This applies when - * {@link is_own_attempt()} returns false. - * - * @return bool whether the review should be allowed. - */ - public function is_review_allowed() { - if (!$this->has_capability('mod/quiz:viewreports')) { - return false; - } - - $cm = $this->get_cm(); - if ($this->has_capability('moodle/site:accessallgroups') || - groups_get_activity_groupmode($cm) != SEPARATEGROUPS) { - return true; - } - - // Check the users have at least one group in common. - $teachersgroups = groups_get_activity_allowed_groups($cm); - $studentsgroups = groups_get_all_groups( - $cm->course, $this->attempt->userid, $cm->groupingid); - return $teachersgroups && $studentsgroups && - array_intersect(array_keys($teachersgroups), array_keys($studentsgroups)); - } - - /** - * Has the student, in this attempt, engaged with the quiz in a non-trivial way? - * - * That is, is there any question worth a non-zero number of marks, where - * the student has made some response that we have saved? - * - * @return bool true if we have saved a response for at least one graded question. - */ - public function has_response_to_at_least_one_graded_question() { - foreach ($this->quba->get_attempt_iterator() as $qa) { - if ($qa->get_max_mark() == 0) { - continue; - } - if ($qa->get_num_steps() > 1) { - return true; - } - } - return false; - } - - /** - * Do any questions in this attempt need to be graded manually? - * - * @return bool True if we have at least one question still needs manual grading. - */ - public function requires_manual_grading(): bool { - return $this->quba->get_total_mark() === null; - } - - /** - * Get extra summary information about this attempt. - * - * Some behaviours may be able to provide interesting summary information - * about the attempt as a whole, and this method provides access to that data. - * To see how this works, try setting a quiz to one of the CBM behaviours, - * and then look at the extra information displayed at the top of the quiz - * review page once you have sumitted an attempt. - * - * In the return value, the array keys are identifiers of the form - * qbehaviour_behaviourname_meaningfullkey. For qbehaviour_deferredcbm_highsummary. - * The values are arrays with two items, title and content. Each of these - * will be either a string, or a renderable. - * - * @param question_display_options $options the display options for this quiz attempt at this time. - * @return array as described above. - */ - public function get_additional_summary_data(question_display_options $options) { - return $this->quba->get_summary_information($options); - } - - /** - * Get the overall feedback corresponding to a particular mark. - * - * @param number $grade a particular grade. - * @return string the feedback. - */ - public function get_overall_feedback($grade) { - return quiz_feedback_for_grade($grade, $this->get_quiz(), - $this->quizobj->get_context()); - } - - /** - * Wrapper round the has_capability funciton that automatically passes in the quiz context. - * - * @param string $capability the name of the capability to check. For example mod/forum:view. - * @param int|null $userid A user id. By default (null) checks the permissions of the current user. - * @param bool $doanything If false, ignore effect of admin role assignment. - * @return boolean true if the user has this capability. Otherwise false. - */ - public function has_capability($capability, $userid = null, $doanything = true) { - return $this->quizobj->has_capability($capability, $userid, $doanything); - } - - /** - * Wrapper round the require_capability function that automatically passes in the quiz context. - * - * @param string $capability the name of the capability to check. For example mod/forum:view. - * @param int|null $userid A user id. By default (null) checks the permissions of the current user. - * @param bool $doanything If false, ignore effect of admin role assignment. - */ - public function require_capability($capability, $userid = null, $doanything = true) { - $this->quizobj->require_capability($capability, $userid, $doanything); - } - - /** - * Check the appropriate capability to see whether this user may review their own attempt. - * If not, prints an error. - */ - public function check_review_capability() { - if ($this->get_attempt_state() == display_options::IMMEDIATELY_AFTER) { - $capability = 'mod/quiz:attempt'; - } else { - $capability = 'mod/quiz:reviewmyattempts'; - } - - // These next tests are in a slighly funny order. The point is that the - // common and most performance-critical case is students attempting a quiz - // so we want to check that permisison first. - - if ($this->has_capability($capability)) { - // User has the permission that lets you do the quiz as a student. Fine. - return; - } - - if ($this->has_capability('mod/quiz:viewreports') || - $this->has_capability('mod/quiz:preview')) { - // User has the permission that lets teachers review. Fine. - return; - } - - // They should not be here. Trigger the standard no-permission error - // but using the name of the student capability. - // We know this will fail. We just want the stadard exception thown. - $this->require_capability($capability); - } - - /** - * Checks whether a user may navigate to a particular slot. - * - * @param int $slot the target slot (currently does not affect the answer). - * @return bool true if the navigation should be allowed. - */ - public function can_navigate_to($slot) { - if ($this->attempt->state == self::OVERDUE) { - // When the attempt is overdue, students can only see the - // attempt summary page and cannot navigate anywhere else. - return false; - } - - switch ($this->get_navigation_method()) { - case QUIZ_NAVMETHOD_FREE: - return true; - break; - case QUIZ_NAVMETHOD_SEQ: - return false; - break; - } - return true; - } - - /** - * @return int one of the display_options::DURING, - * IMMEDIATELY_AFTER, LATER_WHILE_OPEN or AFTER_CLOSE constants. - */ - public function get_attempt_state() { - return quiz_attempt_state($this->get_quiz(), $this->attempt); - } - - /** - * Wrapper that the correct display_options for this quiz at the - * moment. - * - * @param bool $reviewing true for options when reviewing, false for when attempting. - * @return question_display_options the render options for this user on this attempt. - */ - public function get_display_options($reviewing) { - if ($reviewing) { - if (is_null($this->reviewoptions)) { - $this->reviewoptions = quiz_get_review_options($this->get_quiz(), - $this->attempt, $this->quizobj->get_context()); - if ($this->is_own_preview()) { - // It should always be possible for a teacher to review their - // own preview irrespective of the review options settings. - $this->reviewoptions->attempt = true; - } - } - return $this->reviewoptions; - - } else { - $options = display_options::make_from_quiz($this->get_quiz(), - display_options::DURING); - $options->flags = quiz_get_flag_option($this->attempt, $this->quizobj->get_context()); - return $options; - } - } - - /** - * Wrapper that the correct display_options for this quiz at the - * moment. - * - * @param bool $reviewing true for review page, else attempt page. - * @param int $slot which question is being displayed. - * @param moodle_url $thispageurl to return to after the editing form is - * submitted or cancelled. If null, no edit link will be generated. - * - * @return question_display_options the render options for this user on this - * attempt, with extra info to generate an edit link, if applicable. - */ - public function get_display_options_with_edit_link($reviewing, $slot, $thispageurl) { - $options = clone($this->get_display_options($reviewing)); - - if (!$thispageurl) { - return $options; - } - - if (!($reviewing || $this->is_preview())) { - return $options; - } - - $question = $this->quba->get_question($slot, false); - if (!question_has_capability_on($question, 'edit', $question->category)) { - return $options; - } - - $options->editquestionparams['cmid'] = $this->get_cmid(); - $options->editquestionparams['returnurl'] = $thispageurl; - - return $options; - } - - /** - * @param int $page page number - * @return bool true if this is the last page of the quiz. - */ - public function is_last_page($page) { - return $page == count($this->pagelayout) - 1; - } - - /** - * Return the list of slot numbers for either a given page of the quiz, or for the - * whole quiz. - * - * @param mixed $page string 'all' or integer page number. - * @return array the requested list of slot numbers. - */ - public function get_slots($page = 'all') { - if ($page === 'all') { - $numbers = array(); - foreach ($this->pagelayout as $numbersonpage) { - $numbers = array_merge($numbers, $numbersonpage); - } - return $numbers; - } else { - return $this->pagelayout[$page]; - } - } - - /** - * Return the list of slot numbers for either a given page of the quiz, or for the - * whole quiz. - * - * @param mixed $page string 'all' or integer page number. - * @return array the requested list of slot numbers. - */ - public function get_active_slots($page = 'all') { - $activeslots = array(); - foreach ($this->get_slots($page) as $slot) { - if (!$this->is_blocked_by_previous_question($slot)) { - $activeslots[] = $slot; - } - } - return $activeslots; - } - - /** - * Helper method for unit tests. Get the underlying question usage object. - * - * @return question_usage_by_activity the usage. - */ - public function get_question_usage() { - if (!(PHPUNIT_TEST || defined('BEHAT_TEST'))) { - throw new coding_exception('get_question_usage is only for use in unit tests. ' . - 'For other operations, use the quiz_attempt api, or extend it properly.'); - } - return $this->quba; - } - - /** - * Get the question_attempt object for a particular question in this attempt. - * - * @param int $slot the number used to identify this question within this attempt. - * @return question_attempt the requested question_attempt. - */ - public function get_question_attempt($slot) { - return $this->quba->get_question_attempt($slot); - } - - /** - * Get all the question_attempt objects that have ever appeared in a given slot. - * - * This relates to the 'Try another question like this one' feature. - * - * @param int $slot the number used to identify this question within this attempt. - * @return question_attempt[] the attempts. - */ - public function all_question_attempts_originally_in_slot($slot) { - $qas = array(); - foreach ($this->quba->get_attempt_iterator() as $qa) { - if ($qa->get_metadata('originalslot') == $slot) { - $qas[] = $qa; - } - } - $qas[] = $this->quba->get_question_attempt($slot); - return $qas; - } - - /** - * Is a particular question in this attempt a real question, or something like a description. - * - * @param int $slot the number used to identify this question within this attempt. - * @return int whether that question is a real question. Actually returns the - * question length, which could theoretically be greater than one. - */ - public function is_real_question($slot) { - return $this->quba->get_question($slot, false)->length; - } - - /** - * Is a particular question in this attempt a real question, or something like a description. - * - * @param int $slot the number used to identify this question within this attempt. - * @return bool whether that question is a real question. - */ - public function is_question_flagged($slot) { - return $this->quba->get_question_attempt($slot)->is_flagged(); - } - - /** - * Checks whether the question in this slot requires the previous - * question to have been completed. - * - * @param int $slot the number used to identify this question within this attempt. - * @return bool whether the previous question must have been completed before - * this one can be seen. - */ - public function is_blocked_by_previous_question($slot) { - return $slot > 1 && isset($this->slots[$slot]) && $this->slots[$slot]->requireprevious && - !$this->slots[$slot]->section->shufflequestions && - !$this->slots[$slot - 1]->section->shufflequestions && - $this->get_navigation_method() != QUIZ_NAVMETHOD_SEQ && - !$this->get_question_state($slot - 1)->is_finished() && - $this->quba->can_question_finish_during_attempt($slot - 1); - } - - /** - * Is it possible for this question to be re-started within this attempt? - * - * @param int $slot the number used to identify this question within this attempt. - * @return bool whether the student should be given the option to restart this question now. - */ - public function can_question_be_redone_now($slot) { - return $this->get_quiz()->canredoquestions && !$this->is_finished() && - $this->get_question_state($slot)->is_finished(); - } - - /** - * Given a slot in this attempt, which may or not be a redone question, return the original slot. - * - * @param int $slot identifies a particular question in this attempt. - * @return int the slot where this question was originally. - */ - public function get_original_slot($slot) { - $originalslot = $this->quba->get_question_attempt_metadata($slot, 'originalslot'); - if ($originalslot) { - return $originalslot; - } else { - return $slot; - } - } - - /** - * Get the displayed question number for a slot. - * - * @param int $slot the number used to identify this question within this attempt. - * @return string the displayed question number for the question in this slot. - * For example '1', '2', '3' or 'i'. - */ - public function get_question_number($slot) { - return $this->questionnumbers[$slot]; - } - - /** - * If the section heading, if any, that should come just before this slot. - * - * @param int $slot identifies a particular question in this attempt. - * @return string the required heading, or null if there is not one here. - */ - public function get_heading_before_slot($slot) { - if ($this->slots[$slot]->firstinsection) { - return $this->slots[$slot]->section->heading; - } else { - return null; - } - } - - /** - * Return the page of the quiz where this question appears. - * - * @param int $slot the number used to identify this question within this attempt. - * @return int the page of the quiz this question appears on. - */ - public function get_question_page($slot) { - return $this->questionpages[$slot]; - } - - /** - * Return the grade obtained on a particular question, if the user is permitted - * to see it. You must previously have called load_question_states to load the - * state data about this question. - * - * @param int $slot the number used to identify this question within this attempt. - * @return string the formatted grade, to the number of decimal places specified - * by the quiz. - */ - public function get_question_name($slot) { - return $this->quba->get_question($slot, false)->name; - } - - /** - * Return the {@link question_state} that this question is in. - * - * @param int $slot the number used to identify this question within this attempt. - * @return question_state the state this question is in. - */ - public function get_question_state($slot) { - return $this->quba->get_question_state($slot); - } - - /** - * Return the grade obtained on a particular question, if the user is permitted - * to see it. You must previously have called load_question_states to load the - * state data about this question. - * - * @param int $slot the number used to identify this question within this attempt. - * @param bool $showcorrectness Whether right/partial/wrong states should - * be distinguished. - * @return string the formatted grade, to the number of decimal places specified - * by the quiz. - */ - public function get_question_status($slot, $showcorrectness) { - return $this->quba->get_question_state_string($slot, $showcorrectness); - } - - /** - * Return the grade obtained on a particular question, if the user is permitted - * to see it. You must previously have called load_question_states to load the - * state data about this question. - * - * @param int $slot the number used to identify this question within this attempt. - * @param bool $showcorrectness Whether right/partial/wrong states should - * be distinguished. - * @return string class name for this state. - */ - public function get_question_state_class($slot, $showcorrectness) { - return $this->quba->get_question_state_class($slot, $showcorrectness); - } - - /** - * Return the grade obtained on a particular question. - * - * You must previously have called load_question_states to load the state - * data about this question. - * - * @param int $slot the number used to identify this question within this attempt. - * @return string the formatted grade, to the number of decimal places specified by the quiz. - */ - public function get_question_mark($slot) { - return quiz_format_question_grade($this->get_quiz(), $this->quba->get_question_mark($slot)); - } - - /** - * Get the time of the most recent action performed on a question. - * - * @param int $slot the number used to identify this question within this usage. - * @return int timestamp. - */ - public function get_question_action_time($slot) { - return $this->quba->get_question_action_time($slot); - } - - /** - * Return the question type name for a given slot within the current attempt. - * - * @param int $slot the number used to identify this question within this attempt. - * @return string the question type name. - * @since Moodle 3.1 - */ - public function get_question_type_name($slot) { - return $this->quba->get_question($slot, false)->get_type_name(); - } - - /** - * Get the time remaining for an in-progress attempt, if the time is short - * enough that it would be worth showing a timer. - * - * @param int $timenow the time to consider as 'now'. - * @return int|false the number of seconds remaining for this attempt. - * False if there is no limit. - */ - public function get_time_left_display($timenow) { - if ($this->attempt->state != self::IN_PROGRESS) { - return false; - } - return $this->get_access_manager($timenow)->get_time_left_display($this->attempt, $timenow); - } - - - /** - * @return int the time when this attempt was submitted. 0 if it has not been - * submitted yet. - */ - public function get_submitted_date() { - return $this->attempt->timefinish; - } - - /** - * If the attempt is in an applicable state, work out the time by which the - * student should next do something. - * - * @return int timestamp by which the student needs to do something. - */ - public function get_due_date() { - $deadlines = array(); - if ($this->quizobj->get_quiz()->timelimit) { - $deadlines[] = $this->attempt->timestart + $this->quizobj->get_quiz()->timelimit; - } - if ($this->quizobj->get_quiz()->timeclose) { - $deadlines[] = $this->quizobj->get_quiz()->timeclose; - } - if ($deadlines) { - $duedate = min($deadlines); - } else { - return false; - } - - switch ($this->attempt->state) { - case self::IN_PROGRESS: - return $duedate; - - case self::OVERDUE: - return $duedate + $this->quizobj->get_quiz()->graceperiod; - - default: - throw new coding_exception('Unexpected state: ' . $this->attempt->state); - } - } - - // URLs related to this attempt ============================================ - /** - * @return string quiz view url. - */ - public function view_url() { - return $this->quizobj->view_url(); - } - - /** - * Get the URL to start or continue an attempt. - * - * @param int|null $slot which question in the attempt to go to after starting (optional). - * @param int $page which page in the attempt to go to after starting. - * @return string the URL of this quiz's edit page. Needs to be POSTed to with a cmid parameter. - */ - public function start_attempt_url($slot = null, $page = -1) { - if ($page == -1 && !is_null($slot)) { - $page = $this->get_question_page($slot); - } else { - $page = 0; - } - return $this->quizobj->start_attempt_url($page); - } - - /** - * Generates the title of the attempt page. - * - * @param int $page the page number (starting with 0) in the attempt. - * @return string attempt page title. - */ - public function attempt_page_title(int $page) : string { - if ($this->get_num_pages() > 1) { - $a = new stdClass(); - $a->name = $this->get_quiz_name(); - $a->currentpage = $page + 1; - $a->totalpages = $this->get_num_pages(); - $title = get_string('attempttitlepaged', 'quiz', $a); - } else { - $title = get_string('attempttitle', 'quiz', $this->get_quiz_name()); - } - - return $title; - } - - /** - * @param int|null $slot if specified, the slot number of a specific question to link to. - * @param int $page if specified, a particular page to link to. If not given deduced - * from $slot, or goes to the first page. - * @param int $thispage if not -1, the current page. Will cause links to other things on - * this page to be output as only a fragment. - * @return string the URL to continue this attempt. - */ - public function attempt_url($slot = null, $page = -1, $thispage = -1) { - return $this->page_and_question_url('attempt', $slot, $page, false, $thispage); - } - - /** - * Generates the title of the summary page. - * - * @return string summary page title. - */ - public function summary_page_title() : string { - return get_string('attemptsummarytitle', 'quiz', $this->get_quiz_name()); - } - - /** - * @return moodle_url the URL of this quiz's summary page. - */ - public function summary_url() { - return new moodle_url('/mod/quiz/summary.php', array('attempt' => $this->attempt->id, 'cmid' => $this->get_cmid())); - } - - /** - * @return moodle_url the URL of this quiz's summary page. - */ - public function processattempt_url() { - return new moodle_url('/mod/quiz/processattempt.php'); - } - - /** - * Generates the title of the review page. - * - * @param int $page the page number (starting with 0) in the attempt. - * @param bool $showall whether the review page contains the entire attempt on one page. - * @return string title of the review page. - */ - public function review_page_title(int $page, bool $showall = false) : string { - if (!$showall && $this->get_num_pages() > 1) { - $a = new stdClass(); - $a->name = $this->get_quiz_name(); - $a->currentpage = $page + 1; - $a->totalpages = $this->get_num_pages(); - $title = get_string('attemptreviewtitlepaged', 'quiz', $a); - } else { - $title = get_string('attemptreviewtitle', 'quiz', $this->get_quiz_name()); - } - - return $title; - } - - /** - * @param int|null $slot indicates which question to link to. - * @param int $page if specified, the URL of this particular page of the attempt, otherwise - * the URL will go to the first page. If -1, deduce $page from $slot. - * @param bool|null $showall if true, the URL will be to review the entire attempt on one page, - * and $page will be ignored. If null, a sensible default will be chosen. - * @param int $thispage if not -1, the current page. Will cause links to other things on - * this page to be output as only a fragment. - * @return string the URL to review this attempt. - */ - public function review_url($slot = null, $page = -1, $showall = null, $thispage = -1) { - return $this->page_and_question_url('review', $slot, $page, $showall, $thispage); - } - - /** - * By default, should this script show all questions on one page for this attempt? - * - * @param string $script the script name, e.g. 'attempt', 'summary', 'review'. - * @return bool whether show all on one page should be on by default. - */ - public function get_default_show_all($script) { - return $script === 'review' && count($this->questionpages) < self::MAX_SLOTS_FOR_DEFAULT_REVIEW_SHOW_ALL; - } - - // Bits of content ========================================================= - - /** - * If $reviewoptions->attempt is false, meaning that students can't review this - * attempt at the moment, return an appropriate string explaining why. - * - * @param bool $short if true, return a shorter string. - * @return string an appropriate message. - */ - public function cannot_review_message($short = false) { - return $this->quizobj->cannot_review_message( - $this->get_attempt_state(), $short); - } - - /** - * Initialise the JS etc. required all the questions on a page. - * - * @param int|string $page a page number, or 'all'. - * @param bool $showall if true forces page number to all. - * @return string HTML to output - mostly obsolete, will probably be an empty string. - */ - public function get_html_head_contributions($page = 'all', $showall = false) { - if ($showall) { - $page = 'all'; - } - $result = ''; - foreach ($this->get_slots($page) as $slot) { - $result .= $this->quba->render_question_head_html($slot); - } - $result .= question_engine::initialise_js(); - return $result; - } - - /** - * Initialise the JS etc. required by one question. - * - * @param int $slot the question slot number. - * @return string HTML to output - but this is mostly obsolete. Will probably be an empty string. - */ - public function get_question_html_head_contributions($slot) { - return $this->quba->render_question_head_html($slot) . - question_engine::initialise_js(); - } - - /** - * Print the HTML for the start new preview button, if the current user - * is allowed to see one. - * - * @return string HTML for the button. - */ - public function restart_preview_button() { - global $OUTPUT; - if ($this->is_preview() && $this->is_preview_user()) { - return $OUTPUT->single_button(new moodle_url( - $this->start_attempt_url(), array('forcenew' => true)), - get_string('startnewpreview', 'quiz')); - } else { - return ''; - } - } - - /** - * Generate the HTML that displayes the question in its current state, with - * the appropriate display options. - * - * @param int $slot identifies the question in the attempt. - * @param bool $reviewing is the being printed on an attempt or a review page. - * @param renderer $renderer the quiz renderer. - * @param moodle_url $thispageurl the URL of the page this question is being printed on. - * @return string HTML for the question in its current state. - */ - public function render_question($slot, $reviewing, renderer $renderer, $thispageurl = null) { - if ($this->is_blocked_by_previous_question($slot)) { - $placeholderqa = $this->make_blocked_question_placeholder($slot); - - $displayoptions = $this->get_display_options($reviewing); - $displayoptions->manualcomment = question_display_options::HIDDEN; - $displayoptions->history = question_display_options::HIDDEN; - $displayoptions->readonly = true; - - return html_writer::div($placeholderqa->render($displayoptions, - $this->get_question_number($this->get_original_slot($slot))), - 'mod_quiz-blocked_question_warning'); - } - - return $this->render_question_helper($slot, $reviewing, $thispageurl, $renderer, null); - } - - /** - * Helper used by {@link render_question()} and {@link render_question_at_step()}. - * - * @param int $slot identifies the question in the attempt. - * @param bool $reviewing is the being printed on an attempt or a review page. - * @param moodle_url $thispageurl the URL of the page this question is being printed on. - * @param renderer $renderer the quiz renderer. - * @param int|null $seq the seq number of the past state to display. - * @return string HTML fragment. - */ - protected function render_question_helper($slot, $reviewing, $thispageurl, - renderer $renderer, $seq) { - $originalslot = $this->get_original_slot($slot); - $number = $this->get_question_number($originalslot); - $displayoptions = $this->get_display_options_with_edit_link($reviewing, $slot, $thispageurl); - - if ($slot != $originalslot) { - $originalmaxmark = $this->get_question_attempt($slot)->get_max_mark(); - $this->get_question_attempt($slot)->set_max_mark($this->get_question_attempt($originalslot)->get_max_mark()); - } - - if ($this->can_question_be_redone_now($slot)) { - $displayoptions->extrainfocontent = $renderer->redo_question_button( - $slot, $displayoptions->readonly); - } - - if ($displayoptions->history && $displayoptions->questionreviewlink) { - $links = $this->links_to_other_redos($slot, $displayoptions->questionreviewlink); - if ($links) { - $displayoptions->extrahistorycontent = html_writer::tag('p', - get_string('redoesofthisquestion', 'quiz', $renderer->render($links))); - } - } - - if ($seq === null) { - $output = $this->quba->render_question($slot, $displayoptions, $number); - } else { - $output = $this->quba->render_question_at_step($slot, $seq, $displayoptions, $number); - } - - if ($slot != $originalslot) { - $this->get_question_attempt($slot)->set_max_mark($originalmaxmark); - } - - return $output; - } - - /** - * Create a fake question to be displayed in place of a question that is blocked - * until the previous question has been answered. - * - * @param int $slot int slot number of the question to replace. - * @return question_attempt the placeholder question attempt. - */ - protected function make_blocked_question_placeholder($slot) { - $replacedquestion = $this->get_question_attempt($slot)->get_question(false); - - question_bank::load_question_definition_classes('description'); - $question = new qtype_description_question(); - $question->id = $replacedquestion->id; - $question->category = null; - $question->parent = 0; - $question->qtype = question_bank::get_qtype('description'); - $question->name = ''; - $question->questiontext = get_string('questiondependsonprevious', 'quiz'); - $question->questiontextformat = FORMAT_HTML; - $question->generalfeedback = ''; - $question->defaultmark = $this->quba->get_question_max_mark($slot); - $question->length = $replacedquestion->length; - $question->penalty = 0; - $question->stamp = ''; - $question->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY; - $question->timecreated = null; - $question->timemodified = null; - $question->createdby = null; - $question->modifiedby = null; - - $placeholderqa = new question_attempt($question, $this->quba->get_id(), - null, $this->quba->get_question_max_mark($slot)); - $placeholderqa->set_slot($slot); - $placeholderqa->start($this->get_quiz()->preferredbehaviour, 1); - $placeholderqa->set_flagged($this->is_question_flagged($slot)); - return $placeholderqa; - } - - /** - * Like {@link render_question()} but displays the question at the past step - * indicated by $seq, rather than showing the latest step. - * - * @param int $slot the slot number of a question in this quiz attempt. - * @param int $seq the seq number of the past state to display. - * @param bool $reviewing is the being printed on an attempt or a review page. - * @param renderer $renderer the quiz renderer. - * @param moodle_url $thispageurl the URL of the page this question is being printed on. - * @return string HTML for the question in its current state. - */ - public function render_question_at_step($slot, $seq, $reviewing, - renderer $renderer, $thispageurl = null) { - return $this->render_question_helper($slot, $reviewing, $thispageurl, $renderer, $seq); - } - - /** - * Wrapper round print_question from lib/questionlib.php. - * - * @param int $slot the id of a question in this quiz attempt. - * @return string HTML of the question. - */ - public function render_question_for_commenting($slot) { - $options = $this->get_display_options(true); - $options->generalfeedback = question_display_options::HIDDEN; - $options->manualcomment = question_display_options::EDITABLE; - return $this->quba->render_question($slot, $options, - $this->get_question_number($slot)); - } - - /** - * Check wheter access should be allowed to a particular file. - * - * @param int $slot the slot of a question in this quiz attempt. - * @param bool $reviewing is the being printed on an attempt or a review page. - * @param int $contextid the file context id from the request. - * @param string $component the file component from the request. - * @param string $filearea the file area from the request. - * @param array $args extra part components from the request. - * @param bool $forcedownload whether to force download. - * @return string HTML for the question in its current state. - */ - public function check_file_access($slot, $reviewing, $contextid, $component, - $filearea, $args, $forcedownload) { - $options = $this->get_display_options($reviewing); - - // Check permissions - warning there is similar code in review.php and - // reviewquestion.php. If you change on, change them all. - if ($reviewing && $this->is_own_attempt() && !$options->attempt) { - return false; - } - - if ($reviewing && !$this->is_own_attempt() && !$this->is_review_allowed()) { - return false; - } - - return $this->quba->check_file_access($slot, $options, - $component, $filearea, $args, $forcedownload); - } - - /** - * Get the navigation panel object for this attempt. - * - * @param renderer $output the quiz renderer to use to output things. - * @param string $panelclass The type of panel, navigation_panel_attempt::class or navigation_panel_review::class - * @param int $page the current page number. - * @param bool $showall whether we are showing the whole quiz on one page. (Used by review.php.) - * @return block_contents the requested object. - */ - public function get_navigation_panel(renderer $output, - $panelclass, $page, $showall = false) { - $panel = new $panelclass($this, $this->get_display_options(true), $page, $showall); - - $bc = new block_contents(); - $bc->attributes['id'] = 'mod_quiz_navblock'; - $bc->attributes['role'] = 'navigation'; - $bc->title = get_string('quiznavigation', 'quiz'); - $bc->content = $output->navigation_panel($panel); - return $bc; - } - - /** - * Return an array of variant URLs to other attempts at this quiz. - * - * The $url passed in must contain an attempt parameter. - * - * The {@see links_to_other_attempts} object returned contains an - * array with keys that are the attempt number, 1, 2, 3. - * The array values are either a {@link moodle_url} with the attempt parameter - * updated to point to the attempt id of the other attempt, or null corresponding - * to the current attempt number. - * - * @param moodle_url $url a URL. - * @return links_to_other_attempts|bool containing array int => null|moodle_url. - * False if none. - */ - public function links_to_other_attempts(moodle_url $url) { - $attempts = quiz_get_user_attempts($this->get_quiz()->id, $this->attempt->userid, 'all'); - if (count($attempts) <= 1) { - return false; - } - - $links = new links_to_other_attempts(); - foreach ($attempts as $at) { - if ($at->id == $this->attempt->id) { - $links->links[$at->attempt] = null; - } else { - $links->links[$at->attempt] = new moodle_url($url, array('attempt' => $at->id)); - } - } - return $links; - } - - /** - * Return an array of variant URLs to other redos of the question in a particular slot. - * - * The $url passed in must contain a slot parameter. - * - * The {@see links_to_other_attempts} object returned contains an - * array with keys that are the redo number, 1, 2, 3. - * The array values are either a {@see moodle_url} with the slot parameter - * updated to point to the slot that has that redo of this question; or null - * corresponding to the redo identified by $slot. - * - * @param int $slot identifies a question in this attempt. - * @param moodle_url $baseurl the base URL to modify to generate each link. - * @return links_to_other_attempts|null containing array int => null|moodle_url, - * or null if the question in this slot has not been redone. - */ - public function links_to_other_redos($slot, moodle_url $baseurl) { - $originalslot = $this->get_original_slot($slot); - - $qas = $this->all_question_attempts_originally_in_slot($originalslot); - if (count($qas) <= 1) { - return null; - } - - $links = new links_to_other_attempts(); - $index = 1; - foreach ($qas as $qa) { - if ($qa->get_slot() == $slot) { - $links->links[$index] = null; - } else { - $url = new moodle_url($baseurl, array('slot' => $qa->get_slot())); - $links->links[$index] = new action_link($url, $index, - new popup_action('click', $url, 'reviewquestion', - array('width' => 450, 'height' => 650)), - array('title' => get_string('reviewresponse', 'question'))); - } - $index++; - } - return $links; - } - - // Methods for processing ================================================== - - /** - * Check this attempt, to see if there are any state transitions that should - * happen automatically. This function will update the attempt checkstatetime. - * @param int $timestamp the timestamp that should be stored as the modified - * @param bool $studentisonline is the student currently interacting with Moodle? - */ - public function handle_if_time_expired($timestamp, $studentisonline) { - - $timeclose = $this->get_access_manager($timestamp)->get_end_time($this->attempt); - - if ($timeclose === false || $this->is_preview()) { - $this->update_timecheckstate(null); - return; // No time limit. - } - if ($timestamp < $timeclose) { - $this->update_timecheckstate($timeclose); - return; // Time has not yet expired. - } - - // If the attempt is already overdue, look to see if it should be abandoned ... - if ($this->attempt->state == self::OVERDUE) { - $timeoverdue = $timestamp - $timeclose; - $graceperiod = $this->quizobj->get_quiz()->graceperiod; - if ($timeoverdue >= $graceperiod) { - $this->process_abandon($timestamp, $studentisonline); - } else { - // Overdue time has not yet expired - $this->update_timecheckstate($timeclose + $graceperiod); - } - return; // ... and we are done. - } - - if ($this->attempt->state != self::IN_PROGRESS) { - $this->update_timecheckstate(null); - return; // Attempt is already in a final state. - } - - // Otherwise, we were in quiz_attempt::IN_PROGRESS, and time has now expired. - // Transition to the appropriate state. - switch ($this->quizobj->get_quiz()->overduehandling) { - case 'autosubmit': - $this->process_finish($timestamp, false, $studentisonline ? $timestamp : $timeclose, $studentisonline); - return; - - case 'graceperiod': - $this->process_going_overdue($timestamp, $studentisonline); - return; - - case 'autoabandon': - $this->process_abandon($timestamp, $studentisonline); - return; - } - - // This is an overdue attempt with no overdue handling defined, so just abandon. - $this->process_abandon($timestamp, $studentisonline); - return; - } - - /** - * Process all the actions that were submitted as part of the current request. - * - * @param int $timestamp the timestamp that should be stored as the modified. - * time in the database for these actions. If null, will use the current time. - * @param bool $becomingoverdue - * @param array|null $simulatedresponses If not null, then we are testing, and this is an array of simulated data. - * There are two formats supported here, for historical reasons. The newer approach is to pass an array created by - * {@link core_question_generator::get_simulated_post_data_for_questions_in_usage()}. - * the second is to pass an array slot no => contains arrays representing student - * responses which will be passed to {@link question_definition::prepare_simulated_post_data()}. - * This second method will probably get deprecated one day. - */ - public function process_submitted_actions($timestamp, $becomingoverdue = false, $simulatedresponses = null) { - global $DB; - - $transaction = $DB->start_delegated_transaction(); - - if ($simulatedresponses !== null) { - if (is_int(key($simulatedresponses))) { - // Legacy approach. Should be removed one day. - $simulatedpostdata = $this->quba->prepare_simulated_post_data($simulatedresponses); - } else { - $simulatedpostdata = $simulatedresponses; - } - } else { - $simulatedpostdata = null; - } - - $this->quba->process_all_actions($timestamp, $simulatedpostdata); - question_engine::save_questions_usage_by_activity($this->quba); - - $this->attempt->timemodified = $timestamp; - if ($this->attempt->state == self::FINISHED) { - $this->attempt->sumgrades = $this->quba->get_total_mark(); - } - if ($becomingoverdue) { - $this->process_going_overdue($timestamp, true); - } else { - $DB->update_record('quiz_attempts', $this->attempt); - } - - if (!$this->is_preview() && $this->attempt->state == self::FINISHED) { - quiz_save_best_grade($this->get_quiz(), $this->get_userid()); - } - - $transaction->allow_commit(); - } - - /** - * Replace a question in an attempt with a new attempt at the same question. - * - * Well, for randomised questions, it won't be the same question, it will be - * a different randomised selection. - * - * @param int $slot the question to restart. - * @param int $timestamp the timestamp to record for this action. - */ - public function process_redo_question($slot, $timestamp) { - global $DB; - - if (!$this->can_question_be_redone_now($slot)) { - throw new coding_exception('Attempt to restart the question in slot ' . $slot . - ' when it is not in a state to be restarted.'); - } - - $qubaids = new \mod_quiz\question\qubaids_for_users_attempts( - $this->get_quizid(), $this->get_userid(), 'all', true); - - $transaction = $DB->start_delegated_transaction(); - - // Add the question to the usage. It is important we do this before we choose a variant. - $newquestionid = qbank_helper::choose_question_for_redo($this->get_quizid(), - $this->get_quizobj()->get_context(), $this->slots[$slot]->id, $qubaids); - $newquestion = question_bank::load_question($newquestionid, $this->get_quiz()->shuffleanswers); - $newslot = $this->quba->add_question_in_place_of_other($slot, $newquestion); - - // Choose the variant. - if ($newquestion->get_num_variants() == 1) { - $variant = 1; - } else { - $variantstrategy = new core_question\engine\variants\least_used_strategy( - $this->quba, $qubaids); - $variant = $variantstrategy->choose_variant($newquestion->get_num_variants(), - $newquestion->get_variants_selection_seed()); - } - - // Start the question. - $this->quba->start_question($slot, $variant); - $this->quba->set_max_mark($newslot, 0); - $this->quba->set_question_attempt_metadata($newslot, 'originalslot', $slot); - question_engine::save_questions_usage_by_activity($this->quba); - $this->fire_attempt_question_restarted_event($slot, $newquestion->id); - - $transaction->allow_commit(); - } - - /** - * Process all the autosaved data that was part of the current request. - * - * @param int $timestamp the timestamp that should be stored as the modified. - * time in the database for these actions. If null, will use the current time. - */ - public function process_auto_save($timestamp) { - global $DB; - - $transaction = $DB->start_delegated_transaction(); - - $this->quba->process_all_autosaves($timestamp); - question_engine::save_questions_usage_by_activity($this->quba); - $this->fire_attempt_autosaved_event(); - - $transaction->allow_commit(); - } - - /** - * Update the flagged state for all question_attempts in this usage, if their - * flagged state was changed in the request. - */ - public function save_question_flags() { - global $DB; - - $transaction = $DB->start_delegated_transaction(); - $this->quba->update_question_flags(); - question_engine::save_questions_usage_by_activity($this->quba); - $transaction->allow_commit(); - } - - /** - * Submit the attempt. - * - * The separate $timefinish argument should be used when the quiz attempt - * is being processed asynchronously (for example when cron is submitting - * attempts where the time has expired). - * - * @param int $timestamp the time to record as last modified time. - * @param bool $processsubmitted if true, and question responses in the current - * POST request are stored to be graded, before the attempt is finished. - * @param ?int $timefinish if set, use this as the finish time for the attempt. - * (otherwise use $timestamp as the finish time as well). - * @param bool $studentisonline is the student currently interacting with Moodle? - */ - public function process_finish($timestamp, $processsubmitted, $timefinish = null, $studentisonline = false) { - global $DB; - - $transaction = $DB->start_delegated_transaction(); - - if ($processsubmitted) { - $this->quba->process_all_actions($timestamp); - } - $this->quba->finish_all_questions($timestamp); - - question_engine::save_questions_usage_by_activity($this->quba); - - $this->attempt->timemodified = $timestamp; - $this->attempt->timefinish = $timefinish ?? $timestamp; - $this->attempt->sumgrades = $this->quba->get_total_mark(); - $this->attempt->state = self::FINISHED; - $this->attempt->timecheckstate = null; - $this->attempt->gradednotificationsenttime = null; - - if (!$this->requires_manual_grading() || - !has_capability('mod/quiz:emailnotifyattemptgraded', $this->get_quizobj()->get_context(), - $this->get_userid())) { - $this->attempt->gradednotificationsenttime = $this->attempt->timefinish; - } - - $DB->update_record('quiz_attempts', $this->attempt); - - if (!$this->is_preview()) { - quiz_save_best_grade($this->get_quiz(), $this->attempt->userid); - - // Trigger event. - $this->fire_state_transition_event('\mod_quiz\event\attempt_submitted', $timestamp, $studentisonline); - - // Tell any access rules that care that the attempt is over. - $this->get_access_manager($timestamp)->current_attempt_finished(); - } - - $transaction->allow_commit(); - } - - /** - * Update this attempt timecheckstate if necessary. - * - * @param int|null $time the timestamp to set. - */ - public function update_timecheckstate($time) { - global $DB; - if ($this->attempt->timecheckstate !== $time) { - $this->attempt->timecheckstate = $time; - $DB->set_field('quiz_attempts', 'timecheckstate', $time, array('id' => $this->attempt->id)); - } - } - - /** - * Mark this attempt as now overdue. - * - * @param int $timestamp the time to deem as now. - * @param bool $studentisonline is the student currently interacting with Moodle? - */ - public function process_going_overdue($timestamp, $studentisonline) { - global $DB; - - $transaction = $DB->start_delegated_transaction(); - $this->attempt->timemodified = $timestamp; - $this->attempt->state = self::OVERDUE; - // If we knew the attempt close time, we could compute when the graceperiod ends. - // Instead we'll just fix it up through cron. - $this->attempt->timecheckstate = $timestamp; - $DB->update_record('quiz_attempts', $this->attempt); - - $this->fire_state_transition_event('\mod_quiz\event\attempt_becameoverdue', $timestamp, $studentisonline); - - $transaction->allow_commit(); - - quiz_send_overdue_message($this); - } - - /** - * Mark this attempt as abandoned. - * - * @param int $timestamp the time to deem as now. - * @param bool $studentisonline is the student currently interacting with Moodle? - */ - public function process_abandon($timestamp, $studentisonline) { - global $DB; - - $transaction = $DB->start_delegated_transaction(); - $this->attempt->timemodified = $timestamp; - $this->attempt->state = self::ABANDONED; - $this->attempt->timecheckstate = null; - $DB->update_record('quiz_attempts', $this->attempt); - - $this->fire_state_transition_event('\mod_quiz\event\attempt_abandoned', $timestamp, $studentisonline); - - $transaction->allow_commit(); - } - - /** - * Fire a state transition event. - * - * @param string $eventclass the event class name. - * @param int $timestamp the timestamp to include in the event. - * @param bool $studentisonline is the student currently interacting with Moodle? - */ - protected function fire_state_transition_event($eventclass, $timestamp, $studentisonline) { - global $USER; - $quizrecord = $this->get_quiz(); - $params = array( - 'context' => $this->get_quizobj()->get_context(), - 'courseid' => $this->get_courseid(), - 'objectid' => $this->attempt->id, - 'relateduserid' => $this->attempt->userid, - 'other' => array( - 'submitterid' => CLI_SCRIPT ? null : $USER->id, - 'quizid' => $quizrecord->id, - 'studentisonline' => $studentisonline - ) - ); - $event = $eventclass::create($params); - $event->add_record_snapshot('quiz', $this->get_quiz()); - $event->add_record_snapshot('quiz_attempts', $this->get_attempt()); - $event->trigger(); - } - - // Private methods ========================================================= - - /** - * Get a URL for a particular question on a particular page of the quiz. - * Used by {@link attempt_url()} and {@link review_url()}. - * - * @param string $script. Used in the URL like /mod/quiz/$script.php. - * @param int $slot identifies the specific question on the page to jump to. - * 0 to just use the $page parameter. - * @param int $page -1 to look up the page number from the slot, otherwise - * the page number to go to. - * @param bool|null $showall if true, return a URL with showall=1, and not page number. - * if null, then an intelligent default will be chosen. - * @param int $thispage the page we are currently on. Links to questions on this - * page will just be a fragment #q123. -1 to disable this. - * @return moodle_url The requested URL. - */ - protected function page_and_question_url($script, $slot, $page, $showall, $thispage) { - - $defaultshowall = $this->get_default_show_all($script); - if ($showall === null && ($page == 0 || $page == -1)) { - $showall = $defaultshowall; - } - - // Fix up $page. - if ($page == -1) { - if ($slot !== null && !$showall) { - $page = $this->get_question_page($slot); - } else { - $page = 0; - } - } - - if ($showall) { - $page = 0; - } - - // Add a fragment to scroll down to the question. - $fragment = ''; - if ($slot !== null) { - if ($slot == reset($this->pagelayout[$page]) && $thispage != $page) { - // Changing the page, go to top. - $fragment = '#'; - } else { - // Link to the question container. - $qa = $this->get_question_attempt($slot); - $fragment = '#' . $qa->get_outer_question_div_unique_id(); - } - } - - // Work out the correct start to the URL. - if ($thispage == $page) { - return new moodle_url($fragment); - - } else { - $url = new moodle_url('/mod/quiz/' . $script . '.php' . $fragment, - array('attempt' => $this->attempt->id, 'cmid' => $this->get_cmid())); - if ($page == 0 && $showall != $defaultshowall) { - $url->param('showall', (int) $showall); - } else if ($page > 0) { - $url->param('page', $page); - } - return $url; - } - } - - /** - * Process responses during an attempt at a quiz. - * - * @param int $timenow time when the processing started. - * @param bool $finishattempt whether to finish the attempt or not. - * @param bool $timeup true if form was submitted by timer. - * @param int $thispage current page number. - * @return string the attempt state once the data has been processed. - * @since Moodle 3.1 - */ - public function process_attempt($timenow, $finishattempt, $timeup, $thispage) { - global $DB; - - $transaction = $DB->start_delegated_transaction(); - - // Get key times. - $accessmanager = $this->get_access_manager($timenow); - $timeclose = $accessmanager->get_end_time($this->get_attempt()); - $graceperiodmin = get_config('quiz', 'graceperiodmin'); - - // Don't enforce timeclose for previews. - if ($this->is_preview()) { - $timeclose = false; - } - - // Check where we are in relation to the end time, if there is one. - $toolate = false; - if ($timeclose !== false) { - if ($timenow > $timeclose - QUIZ_MIN_TIME_TO_CONTINUE) { - // If there is only a very small amount of time left, there is no point trying - // to show the student another page of the quiz. Just finish now. - $timeup = true; - if ($timenow > $timeclose + $graceperiodmin) { - $toolate = true; - } - } else { - // If time is not close to expiring, then ignore the client-side timer's opinion - // about whether time has expired. This can happen if the time limit has changed - // since the student's previous interaction. - $timeup = false; - } - } - - // If time is running out, trigger the appropriate action. - $becomingoverdue = false; - $becomingabandoned = false; - if ($timeup) { - if ($this->get_quiz()->overduehandling === 'graceperiod') { - if ($timenow > $timeclose + $this->get_quiz()->graceperiod + $graceperiodmin) { - // Grace period has run out. - $finishattempt = true; - $becomingabandoned = true; - } else { - $becomingoverdue = true; - } - } else { - $finishattempt = true; - } - } - - if (!$finishattempt) { - // Just process the responses for this page and go to the next page. - if (!$toolate) { - try { - $this->process_submitted_actions($timenow, $becomingoverdue); - $this->fire_attempt_updated_event(); - } catch (question_out_of_sequence_exception $e) { - throw new moodle_exception('submissionoutofsequencefriendlymessage', 'question', - $this->attempt_url(null, $thispage)); - - } catch (Exception $e) { - // This sucks, if we display our own custom error message, there is no way - // to display the original stack trace. - $debuginfo = ''; - if (!empty($e->debuginfo)) { - $debuginfo = $e->debuginfo; - } - throw new moodle_exception('errorprocessingresponses', 'question', - $this->attempt_url(null, $thispage), $e->getMessage(), $debuginfo); - } - - if (!$becomingoverdue) { - foreach ($this->get_slots() as $slot) { - if (optional_param('redoslot' . $slot, false, PARAM_BOOL)) { - $this->process_redo_question($slot, $timenow); - } - } - } - - } else { - // The student is too late. - $this->process_going_overdue($timenow, true); - } - - $transaction->allow_commit(); - - return $becomingoverdue ? self::OVERDUE : self::IN_PROGRESS; - } - - // Update the quiz attempt record. - try { - if ($becomingabandoned) { - $this->process_abandon($timenow, true); - } else { - if (!$toolate || $this->get_quiz()->overduehandling === 'graceperiod') { - // Normally, we record the accurate finish time when the student is online. - $finishtime = $timenow; - } else { - // But, if there is no grade period, and the final responses were too - // late to be processed, record the close time, to reduce confusion. - $finishtime = $timeclose; - } - $this->process_finish($timenow, !$toolate, $finishtime, true); - } - - } catch (question_out_of_sequence_exception $e) { - throw new moodle_exception('submissionoutofsequencefriendlymessage', 'question', - $this->attempt_url(null, $thispage)); - - } catch (Exception $e) { - // This sucks, if we display our own custom error message, there is no way - // to display the original stack trace. - $debuginfo = ''; - if (!empty($e->debuginfo)) { - $debuginfo = $e->debuginfo; - } - throw new moodle_exception('errorprocessingresponses', 'question', - $this->attempt_url(null, $thispage), $e->getMessage(), $debuginfo); - } - - // Send the user to the review page. - $transaction->allow_commit(); - - return $becomingabandoned ? self::ABANDONED : self::FINISHED; - } - - /** - * Check a page read access to see if is an out of sequence access. - * - * If allownext is set then we also check whether access to the page - * after the current one should be permitted. - * - * @param int $page page number. - * @param bool $allownext in case of a sequential navigation, can we go to next page ? - * @return boolean false is an out of sequence access, true otherwise. - * @since Moodle 3.1 - */ - public function check_page_access(int $page, bool $allownext = true): bool { - if ($this->get_navigation_method() != QUIZ_NAVMETHOD_SEQ) { - return true; - } - // Sequential access: allow access to the summary, current page or next page. - // Or if the user review his/her attempt, see MDLQA-1523. - return $page == -1 - || $page == $this->get_currentpage() - || $allownext && ($page == $this->get_currentpage() + 1); - } - - /** - * Update attempt page. - * - * @param int $page page number. - * @return boolean true if everything was ok, false otherwise (out of sequence access). - * @since Moodle 3.1 - */ - public function set_currentpage($page) { - global $DB; - - if ($this->check_page_access($page)) { - $DB->set_field('quiz_attempts', 'currentpage', $page, array('id' => $this->get_attemptid())); - return true; - } - return false; - } - - /** - * Trigger the attempt_viewed event. - * - * @since Moodle 3.1 - */ - public function fire_attempt_viewed_event() { - $params = array( - 'objectid' => $this->get_attemptid(), - 'relateduserid' => $this->get_userid(), - 'courseid' => $this->get_courseid(), - 'context' => context_module::instance($this->get_cmid()), - 'other' => array( - 'quizid' => $this->get_quizid(), - 'page' => $this->get_currentpage() - ) - ); - $event = \mod_quiz\event\attempt_viewed::create($params); - $event->add_record_snapshot('quiz_attempts', $this->get_attempt()); - $event->trigger(); - } - - /** - * Trigger the attempt_updated event. - * - * @return void - */ - public function fire_attempt_updated_event(): void { - $params = [ - 'objectid' => $this->get_attemptid(), - 'relateduserid' => $this->get_userid(), - 'courseid' => $this->get_courseid(), - 'context' => context_module::instance($this->get_cmid()), - 'other' => [ - 'quizid' => $this->get_quizid(), - 'page' => $this->get_currentpage() - ] - ]; - $event = \mod_quiz\event\attempt_updated::create($params); - $event->add_record_snapshot('quiz_attempts', $this->get_attempt()); - $event->trigger(); - } - - /** - * Trigger the attempt_autosaved event. - * - * @return void - */ - public function fire_attempt_autosaved_event(): void { - $params = [ - 'objectid' => $this->get_attemptid(), - 'relateduserid' => $this->get_userid(), - 'courseid' => $this->get_courseid(), - 'context' => context_module::instance($this->get_cmid()), - 'other' => [ - 'quizid' => $this->get_quizid(), - 'page' => $this->get_currentpage() - ] - ]; - $event = \mod_quiz\event\attempt_autosaved::create($params); - $event->add_record_snapshot('quiz_attempts', $this->get_attempt()); - $event->trigger(); - } - - /** - * Trigger the attempt_question_restarted event. - * - * @param int $slot Slot number - * @param int $newquestionid New question id. - * @return void - */ - public function fire_attempt_question_restarted_event(int $slot, int $newquestionid): void { - $params = [ - 'objectid' => $this->get_attemptid(), - 'relateduserid' => $this->get_userid(), - 'courseid' => $this->get_courseid(), - 'context' => context_module::instance($this->get_cmid()), - 'other' => [ - 'quizid' => $this->get_quizid(), - 'page' => $this->get_currentpage(), - 'slot' => $slot, - 'newquestionid' => $newquestionid - ] - ]; - $event = \mod_quiz\event\attempt_question_restarted::create($params); - $event->add_record_snapshot('quiz_attempts', $this->get_attempt()); - $event->trigger(); - } - - /** - * Trigger the attempt_summary_viewed event. - * - * @since Moodle 3.1 - */ - public function fire_attempt_summary_viewed_event() { - - $params = array( - 'objectid' => $this->get_attemptid(), - 'relateduserid' => $this->get_userid(), - 'courseid' => $this->get_courseid(), - 'context' => context_module::instance($this->get_cmid()), - 'other' => array( - 'quizid' => $this->get_quizid() - ) - ); - $event = \mod_quiz\event\attempt_summary_viewed::create($params); - $event->add_record_snapshot('quiz_attempts', $this->get_attempt()); - $event->trigger(); - } - - /** - * Trigger the attempt_reviewed event. - * - * @since Moodle 3.1 - */ - public function fire_attempt_reviewed_event() { - - $params = array( - 'objectid' => $this->get_attemptid(), - 'relateduserid' => $this->get_userid(), - 'courseid' => $this->get_courseid(), - 'context' => context_module::instance($this->get_cmid()), - 'other' => array( - 'quizid' => $this->get_quizid() - ) - ); - $event = \mod_quiz\event\attempt_reviewed::create($params); - $event->add_record_snapshot('quiz_attempts', $this->get_attempt()); - $event->trigger(); - } - - /** - * Trigger the attempt manual grading completed event. - */ - public function fire_attempt_manual_grading_completed_event() { - $params = [ - 'objectid' => $this->get_attemptid(), - 'relateduserid' => $this->get_userid(), - 'courseid' => $this->get_courseid(), - 'context' => context_module::instance($this->get_cmid()), - 'other' => [ - 'quizid' => $this->get_quizid() - ] - ]; - - $event = \mod_quiz\event\attempt_manual_grading_completed::create($params); - $event->add_record_snapshot('quiz_attempts', $this->get_attempt()); - $event->trigger(); - } - - /** - * Update the timemodifiedoffline attempt field. - * - * This function should be used only when web services are being used. - * - * @param int $time time stamp. - * @return boolean false if the field is not updated because web services aren't being used. - * @since Moodle 3.2 - */ - public function set_offline_modified_time($time) { - // Update the timemodifiedoffline field only if web services are being used. - if (WS_SERVER) { - $this->attempt->timemodifiedoffline = $time; - return true; - } - return false; - } - - /** - * Get the total number of unanswered questions in the attempt. - * - * @return int - */ - public function get_number_of_unanswered_questions(): int { - $totalunanswered = 0; - foreach ($this->get_slots() as $slot) { - $questionstate = $this->get_question_state($slot); - if ($questionstate == question_state::$todo || $questionstate == question_state::$invalid) { - $totalunanswered++; - } - } - return $totalunanswered; - } -} diff --git a/mod/quiz/classes/access_manager.php b/mod/quiz/classes/access_manager.php index c17893cd13f..8ba515df7af 100644 --- a/mod/quiz/classes/access_manager.php +++ b/mod/quiz/classes/access_manager.php @@ -26,7 +26,6 @@ use moodle_page; use moodle_url; use MoodleQuickForm; use quiz; -use quiz_attempt; use stdClass; /** diff --git a/mod/quiz/classes/external.php b/mod/quiz/classes/external.php index 0b23ad3add2..da33c5da4f3 100644 --- a/mod/quiz/classes/external.php +++ b/mod/quiz/classes/external.php @@ -26,6 +26,7 @@ use core_course\external\helper_for_get_mods_by_courses; use mod_quiz\access_manager; +use mod_quiz\quiz_attempt; defined('MOODLE_INTERNAL') || die; diff --git a/mod/quiz/classes/local/reports/attempts_report_options.php b/mod/quiz/classes/local/reports/attempts_report_options.php index b66872845fe..5d8f92383d5 100644 --- a/mod/quiz/classes/local/reports/attempts_report_options.php +++ b/mod/quiz/classes/local/reports/attempts_report_options.php @@ -17,8 +17,8 @@ namespace mod_quiz\local\reports; use context_module; +use mod_quiz\quiz_attempt; use moodle_url; -use quiz_attempt; use stdClass; /** diff --git a/mod/quiz/classes/local/reports/attempts_report_table.php b/mod/quiz/classes/local/reports/attempts_report_table.php index 993e6385e3e..d55d0131272 100644 --- a/mod/quiz/classes/local/reports/attempts_report_table.php +++ b/mod/quiz/classes/local/reports/attempts_report_table.php @@ -23,6 +23,7 @@ require_once($CFG->libdir.'/tablelib.php'); use coding_exception; use context_module; use html_writer; +use mod_quiz\quiz_attempt; use moodle_url; use popup_action; use question_state; @@ -30,7 +31,6 @@ use qubaid_condition; use qubaid_join; use qubaid_list; use question_engine_data_mapper; -use quiz_attempt; use stdClass; /** diff --git a/mod/quiz/classes/output/links_to_other_attempts.php b/mod/quiz/classes/output/links_to_other_attempts.php index 7a94410281d..3fede712925 100644 --- a/mod/quiz/classes/output/links_to_other_attempts.php +++ b/mod/quiz/classes/output/links_to_other_attempts.php @@ -32,7 +32,7 @@ class links_to_other_attempts implements renderable { * - null if this is the current attempt, and so should not be linked. (Just the number is output.) * - moodle_url if this is a different attempt. (Output as a link to the URL with the number as link text.) * - a renderable, in which case the results of rendering the renderable is output. - * (This is used by {@see quiz_attempt::links_to_other_redos()}.) + * (The third option is used by {@see quiz_attempt::links_to_other_redos()}.) */ public $links = []; } diff --git a/mod/quiz/classes/output/navigation_panel_base.php b/mod/quiz/classes/output/navigation_panel_base.php index 057729f9415..6de96a68b1a 100644 --- a/mod/quiz/classes/output/navigation_panel_base.php +++ b/mod/quiz/classes/output/navigation_panel_base.php @@ -16,12 +16,11 @@ namespace mod_quiz\output; -use moodle_page; +use mod_quiz\quiz_attempt; use moodle_url; use question_attempt; use question_display_options; use question_state; -use quiz_attempt; use renderable; use user_picture; diff --git a/mod/quiz/classes/output/renderer.php b/mod/quiz/classes/output/renderer.php index 9c691d899d7..0f62095b2fa 100644 --- a/mod/quiz/classes/output/renderer.php +++ b/mod/quiz/classes/output/renderer.php @@ -26,12 +26,12 @@ use html_writer; use mod_quiz\access_manager; use mod_quiz\form\preflight_check_form; use mod_quiz\question\display_options; +use mod_quiz\quiz_attempt; use moodle_url; use plugin_renderer_base; use popup_action; use question_display_options; use quiz; -use quiz_attempt; use renderable; use single_button; use stdClass; diff --git a/mod/quiz/classes/output/view_page.php b/mod/quiz/classes/output/view_page.php index 6475a31867d..ec437bfafa7 100644 --- a/mod/quiz/classes/output/view_page.php +++ b/mod/quiz/classes/output/view_page.php @@ -18,6 +18,7 @@ namespace mod_quiz\output; use mod_quiz\access_manager; use mod_quiz\form\preflight_check_form; +use mod_quiz\quiz_attempt; use moodle_url; /** @@ -36,7 +37,7 @@ class view_page { public $infomessages; /** @var array $attempts contains all the user's attempts at this quiz. */ public $attempts; - /** @var array $attemptobjs quiz_attempt objects corresponding to $attempts. */ + /** @var quiz_attempt[] $attemptobjs objects corresponding to $attempts. */ public $attemptobjs; /** @var access_manager $accessmanager contains various access rules. */ public $accessmanager; diff --git a/mod/quiz/classes/privacy/provider.php b/mod/quiz/classes/privacy/provider.php index 82b4e9e58f8..94cbc33e3a2 100644 --- a/mod/quiz/classes/privacy/provider.php +++ b/mod/quiz/classes/privacy/provider.php @@ -34,6 +34,7 @@ use core_privacy\local\metadata\collection; use core_privacy\local\request\userlist; use core_privacy\local\request\writer; use core_privacy\manager; +use mod_quiz\quiz_attempt; defined('MOODLE_INTERNAL') || die(); @@ -526,7 +527,7 @@ class provider implements // Store the quiz attempt data. $data = (object) [ - 'state' => \quiz_attempt::state_name($attempt->state), + 'state' => quiz_attempt::state_name($attempt->state), ]; if (!empty($attempt->timestart)) { diff --git a/mod/quiz/classes/question/qubaids_for_quiz.php b/mod/quiz/classes/question/qubaids_for_quiz.php index e42a650a838..5583349d061 100644 --- a/mod/quiz/classes/question/qubaids_for_quiz.php +++ b/mod/quiz/classes/question/qubaids_for_quiz.php @@ -16,6 +16,8 @@ namespace mod_quiz\question; +use mod_quiz\quiz_attempt; + defined('MOODLE_INTERNAL') || die(); require_once($CFG->dirroot . '/question/engine/datalib.php'); @@ -47,7 +49,7 @@ class qubaids_for_quiz extends \qubaid_join { if ($onlyfinished) { $where .= ' AND state = :statefinished'; - $params['statefinished'] = \quiz_attempt::FINISHED; + $params['statefinished'] = quiz_attempt::FINISHED; } parent::__construct('{quiz_attempts} quiza', 'quiza.uniqueid', $where, $params); diff --git a/mod/quiz/classes/question/qubaids_for_users_attempts.php b/mod/quiz/classes/question/qubaids_for_users_attempts.php index b3c102ca835..2f4c7ba1abf 100644 --- a/mod/quiz/classes/question/qubaids_for_users_attempts.php +++ b/mod/quiz/classes/question/qubaids_for_users_attempts.php @@ -16,6 +16,8 @@ namespace mod_quiz\question; +use mod_quiz\quiz_attempt; + defined('MOODLE_INTERNAL') || die(); require_once($CFG->dirroot . '/question/engine/datalib.php'); @@ -54,14 +56,14 @@ class qubaids_for_users_attempts extends \qubaid_join { case 'finished': $where .= ' AND state IN (:state1, :state2)'; - $params['state1'] = \quiz_attempt::FINISHED; - $params['state2'] = \quiz_attempt::ABANDONED; + $params['state1'] = quiz_attempt::FINISHED; + $params['state2'] = quiz_attempt::ABANDONED; break; case 'unfinished': $where .= ' AND state IN (:state1, :state2)'; - $params['state1'] = \quiz_attempt::IN_PROGRESS; - $params['state2'] = \quiz_attempt::OVERDUE; + $params['state1'] = quiz_attempt::IN_PROGRESS; + $params['state2'] = quiz_attempt::OVERDUE; break; } diff --git a/mod/quiz/classes/quiz_attempt.php b/mod/quiz/classes/quiz_attempt.php new file mode 100644 index 00000000000..df2897499bf --- /dev/null +++ b/mod/quiz/classes/quiz_attempt.php @@ -0,0 +1,2196 @@ +. + +namespace mod_quiz; + +use action_link; +use block_contents; +use coding_exception; +use context_module; +use dml_exception; +use html_writer; +use mod_quiz\output\links_to_other_attempts; +use mod_quiz\output\renderer; +use mod_quiz\question\bank\qbank_helper; +use mod_quiz\question\display_options; +use moodle_exception; +use moodle_url; +use popup_action; +use qtype_description_question; +use question_attempt; +use question_bank; +use question_display_options; +use question_engine; +use question_out_of_sequence_exception; +use question_state; +use question_usage_by_activity; +use quiz; +use stdClass; + +/** + * This class represents one user's attept at a particular quiz. + * + * @package mod_quiz + * @copyright 2008 Tim Hunt + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class quiz_attempt { + + /** @var string to identify the in progress state. */ + const IN_PROGRESS = 'inprogress'; + /** @var string to identify the overdue state. */ + const OVERDUE = 'overdue'; + /** @var string to identify the finished state. */ + const FINISHED = 'finished'; + /** @var string to identify the abandoned state. */ + const ABANDONED = 'abandoned'; + + /** @var int maximum number of slots in the quiz for the review page to default to show all. */ + const MAX_SLOTS_FOR_DEFAULT_REVIEW_SHOW_ALL = 50; + + /** @var quiz object containing the quiz settings. */ + protected $quizobj; + + /** @var stdClass the quiz_attempts row. */ + protected $attempt; + + /** @var question_usage_by_activity the question usage for this quiz attempt. */ + protected $quba; + + /** + * @var array of slot information. These objects contain ->slot (int), + * ->requireprevious (bool), ->questionids (int) the original question for random questions, + * ->firstinsection (bool), ->section (stdClass from $this->sections). + * This does not contain page - get that from {@link get_question_page()} - + * or maxmark - get that from $this->quba. + */ + protected $slots; + + /** @var array of quiz_sections rows, with a ->lastslot field added. */ + protected $sections; + + /** @var array page no => array of slot numbers on the page in order. */ + protected $pagelayout; + + /** @var array slot => displayed question number for this slot. (E.g. 1, 2, 3 or 'i'.) */ + protected $questionnumbers; + + /** @var array slot => page number for this slot. */ + protected $questionpages; + + /** @var display_options cache for the appropriate review options. */ + protected $reviewoptions = null; + + // Constructor ============================================================= + /** + * Constructor assuming we already have the necessary data loaded. + * + * @param object $attempt the row of the quiz_attempts table. + * @param object $quiz the quiz object for this attempt and user. + * @param object $cm the course_module object for this quiz. + * @param object $course the row from the course table for the course we belong to. + * @param bool $loadquestions (optional) if true, the default, load all the details + * of the state of each question. Else just set up the basic details of the attempt. + */ + public function __construct($attempt, $quiz, $cm, $course, $loadquestions = true) { + $this->attempt = $attempt; + $this->quizobj = new quiz($quiz, $cm, $course); + + if ($loadquestions) { + $this->load_questions(); + } + } + + /** + * Used by {create()} and {create_from_usage_id()}. + * + * @param array $conditions passed to $DB->get_record('quiz_attempts', $conditions). + * @return quiz_attempt the desired instance of this class. + */ + protected static function create_helper($conditions) { + global $DB; + + $attempt = $DB->get_record('quiz_attempts', $conditions, '*', MUST_EXIST); + $quiz = access_manager::load_quiz_and_settings($attempt->quiz); + $course = $DB->get_record('course', array('id' => $quiz->course), '*', MUST_EXIST); + $cm = get_coursemodule_from_instance('quiz', $quiz->id, $course->id, false, MUST_EXIST); + + // Update quiz with override information. + $quiz = quiz_update_effective_access($quiz, $attempt->userid); + + return new quiz_attempt($attempt, $quiz, $cm, $course); + } + + /** + * Static function to create a new quiz_attempt object given an attemptid. + * + * @param int $attemptid the attempt id. + * @return quiz_attempt the new quiz_attempt object + */ + public static function create($attemptid) { + return self::create_helper(array('id' => $attemptid)); + } + + /** + * Static function to create a new quiz_attempt object given a usage id. + * + * @param int $usageid the attempt usage id. + * @return quiz_attempt the new quiz_attempt object + */ + public static function create_from_usage_id($usageid) { + return self::create_helper(array('uniqueid' => $usageid)); + } + + /** + * @param string $state one of the state constants like IN_PROGRESS. + * @return string the human-readable state name. + */ + public static function state_name($state) { + return quiz_attempt_state_name($state); + } + + /** + * This method can be called later if the object was constructed with $loadqusetions = false. + */ + public function load_questions() { + global $DB; + + if (isset($this->quba)) { + throw new coding_exception('This quiz attempt has already had the questions loaded.'); + } + + $this->quba = question_engine::load_questions_usage_by_activity($this->attempt->uniqueid); + $this->slots = $DB->get_records('quiz_slots', + ['quizid' => $this->get_quizid()], 'slot', 'slot, id, requireprevious, displaynumber'); + $this->sections = array_values($DB->get_records('quiz_sections', + ['quizid' => $this->get_quizid()], 'firstslot')); + + $this->link_sections_and_slots(); + $this->determine_layout(); + $this->number_questions(); + } + + /** + * Preload all attempt step users to show in Response history. + * + * @throws dml_exception + */ + public function preload_all_attempt_step_users(): void { + $this->quba->preload_all_step_users(); + } + + /** + * Let each slot know which section it is part of. + */ + protected function link_sections_and_slots() { + foreach ($this->sections as $i => $section) { + if (isset($this->sections[$i + 1])) { + $section->lastslot = $this->sections[$i + 1]->firstslot - 1; + } else { + $section->lastslot = count($this->slots); + } + for ($slot = $section->firstslot; $slot <= $section->lastslot; $slot += 1) { + $this->slots[$slot]->section = $section; + } + } + } + + /** + * Parse attempt->layout to populate the other arrays the represent the layout. + */ + protected function determine_layout() { + $this->pagelayout = array(); + + // Break up the layout string into pages. + $pagelayouts = explode(',0', $this->attempt->layout); + + // Strip off any empty last page (normally there is one). + if (end($pagelayouts) == '') { + array_pop($pagelayouts); + } + + // File the ids into the arrays. + // Tracking which is the first slot in each section in this attempt is + // trickier than you might guess, since the slots in this section + // may be shuffled, so $section->firstslot (the lowest numbered slot in + // the section) may not be the first one. + $unseensections = $this->sections; + $this->pagelayout = array(); + foreach ($pagelayouts as $page => $pagelayout) { + $pagelayout = trim($pagelayout, ','); + if ($pagelayout == '') { + continue; + } + $this->pagelayout[$page] = explode(',', $pagelayout); + foreach ($this->pagelayout[$page] as $slot) { + $sectionkey = array_search($this->slots[$slot]->section, $unseensections); + if ($sectionkey !== false) { + $this->slots[$slot]->firstinsection = true; + unset($unseensections[$sectionkey]); + } else { + $this->slots[$slot]->firstinsection = false; + } + } + } + } + + /** + * Work out the number to display for each question/slot. + */ + protected function number_questions() { + $number = 1; + foreach ($this->pagelayout as $page => $slots) { + foreach ($slots as $slot) { + if ($length = $this->is_real_question($slot)) { + // Whether question numbering is customised or is numeric and automatically incremented. + if (!empty($this->slots[$slot]->displaynumber) && !is_null($this->slots[$slot]->displaynumber)) { + $this->questionnumbers[$slot] = $this->slots[$slot]->displaynumber; + } else { + $this->questionnumbers[$slot] = $number; + } + $number += $length; + } else { + $this->questionnumbers[$slot] = get_string('infoshort', 'quiz'); + } + $this->questionpages[$slot] = $page; + } + } + } + + /** + * If the given page number is out of range (before the first page, or after + * the last page, chnage it to be within range). + * + * @param int $page the requested page number. + * @return int a safe page number to use. + */ + public function force_page_number_into_range($page) { + return min(max($page, 0), count($this->pagelayout) - 1); + } + + // Simple getters ========================================================== + public function get_quiz() { + return $this->quizobj->get_quiz(); + } + + public function get_quizobj() { + return $this->quizobj; + } + + /** @return int the course id. */ + public function get_courseid() { + return $this->quizobj->get_courseid(); + } + + /** + * Get the course settings object. + * + * @return stdClass the course settings object. + */ + public function get_course() { + return $this->quizobj->get_course(); + } + + /** @return int the quiz id. */ + public function get_quizid() { + return $this->quizobj->get_quizid(); + } + + /** @return string the name of this quiz. */ + public function get_quiz_name() { + return $this->quizobj->get_quiz_name(); + } + + /** @return int the quiz navigation method. */ + public function get_navigation_method() { + return $this->quizobj->get_navigation_method(); + } + + /** @return object the course_module object. */ + public function get_cm() { + return $this->quizobj->get_cm(); + } + + /** + * Get the course-module id. + * + * @return int the course_module id. + */ + public function get_cmid() { + return $this->quizobj->get_cmid(); + } + + /** + * @return bool whether the current user is someone who previews the quiz, + * rather than attempting it. + */ + public function is_preview_user() { + return $this->quizobj->is_preview_user(); + } + + /** @return int the number of attempts allowed at this quiz (0 = infinite). */ + public function get_num_attempts_allowed() { + return $this->quizobj->get_num_attempts_allowed(); + } + + /** @return int number fo pages in this quiz. */ + public function get_num_pages() { + return count($this->pagelayout); + } + + /** + * @param int $timenow the current time as a unix timestamp. + * @return access_manager and instance of the access_manager class + * for this quiz at this time. + */ + public function get_access_manager($timenow) { + return $this->quizobj->get_access_manager($timenow); + } + + /** @return int the attempt id. */ + public function get_attemptid() { + return $this->attempt->id; + } + + /** @return int the attempt unique id. */ + public function get_uniqueid() { + return $this->attempt->uniqueid; + } + + /** @return object the row from the quiz_attempts table. */ + public function get_attempt() { + return $this->attempt; + } + + /** @return int the number of this attemp (is it this user's first, second, ... attempt). */ + public function get_attempt_number() { + return $this->attempt->attempt; + } + + /** @return string one of the quiz_attempt::IN_PROGRESS, FINISHED, OVERDUE or ABANDONED constants. */ + public function get_state() { + return $this->attempt->state; + } + + /** @return int the id of the user this attempt belongs to. */ + public function get_userid() { + return $this->attempt->userid; + } + + /** @return int the current page of the attempt. */ + public function get_currentpage() { + return $this->attempt->currentpage; + } + + public function get_sum_marks() { + return $this->attempt->sumgrades; + } + + /** + * @return bool whether this attempt has been finished (true) or is still + * in progress (false). Be warned that this is not just state == self::FINISHED, + * it also includes self::ABANDONED. + */ + public function is_finished() { + return $this->attempt->state == self::FINISHED || $this->attempt->state == self::ABANDONED; + } + + /** @return bool whether this attempt is a preview attempt. */ + public function is_preview() { + return $this->attempt->preview; + } + + /** + * Is this someone dealing with their own attempt or preview? + * + * @return bool true => own attempt/preview. false => reviewing someone else's. + */ + public function is_own_attempt() { + global $USER; + return $this->attempt->userid == $USER->id; + } + + /** + * @return bool whether this attempt is a preview belonging to the current user. + */ + public function is_own_preview() { + return $this->is_own_attempt() && + $this->is_preview_user() && $this->attempt->preview; + } + + /** + * Is the current user allowed to review this attempt. This applies when + * {@link is_own_attempt()} returns false. + * + * @return bool whether the review should be allowed. + */ + public function is_review_allowed() { + if (!$this->has_capability('mod/quiz:viewreports')) { + return false; + } + + $cm = $this->get_cm(); + if ($this->has_capability('moodle/site:accessallgroups') || + groups_get_activity_groupmode($cm) != SEPARATEGROUPS) { + return true; + } + + // Check the users have at least one group in common. + $teachersgroups = groups_get_activity_allowed_groups($cm); + $studentsgroups = groups_get_all_groups( + $cm->course, $this->attempt->userid, $cm->groupingid); + return $teachersgroups && $studentsgroups && + array_intersect(array_keys($teachersgroups), array_keys($studentsgroups)); + } + + /** + * Has the student, in this attempt, engaged with the quiz in a non-trivial way? + * + * That is, is there any question worth a non-zero number of marks, where + * the student has made some response that we have saved? + * + * @return bool true if we have saved a response for at least one graded question. + */ + public function has_response_to_at_least_one_graded_question() { + foreach ($this->quba->get_attempt_iterator() as $qa) { + if ($qa->get_max_mark() == 0) { + continue; + } + if ($qa->get_num_steps() > 1) { + return true; + } + } + return false; + } + + /** + * Do any questions in this attempt need to be graded manually? + * + * @return bool True if we have at least one question still needs manual grading. + */ + public function requires_manual_grading(): bool { + return $this->quba->get_total_mark() === null; + } + + /** + * Get extra summary information about this attempt. + * + * Some behaviours may be able to provide interesting summary information + * about the attempt as a whole, and this method provides access to that data. + * To see how this works, try setting a quiz to one of the CBM behaviours, + * and then look at the extra information displayed at the top of the quiz + * review page once you have sumitted an attempt. + * + * In the return value, the array keys are identifiers of the form + * qbehaviour_behaviourname_meaningfullkey. For qbehaviour_deferredcbm_highsummary. + * The values are arrays with two items, title and content. Each of these + * will be either a string, or a renderable. + * + * @param question_display_options $options the display options for this quiz attempt at this time. + * @return array as described above. + */ + public function get_additional_summary_data(question_display_options $options) { + return $this->quba->get_summary_information($options); + } + + /** + * Get the overall feedback corresponding to a particular mark. + * + * @param number $grade a particular grade. + * @return string the feedback. + */ + public function get_overall_feedback($grade) { + return quiz_feedback_for_grade($grade, $this->get_quiz(), + $this->quizobj->get_context()); + } + + /** + * Wrapper round the has_capability funciton that automatically passes in the quiz context. + * + * @param string $capability the name of the capability to check. For example mod/forum:view. + * @param int|null $userid A user id. By default (null) checks the permissions of the current user. + * @param bool $doanything If false, ignore effect of admin role assignment. + * @return boolean true if the user has this capability. Otherwise false. + */ + public function has_capability($capability, $userid = null, $doanything = true) { + return $this->quizobj->has_capability($capability, $userid, $doanything); + } + + /** + * Wrapper round the require_capability function that automatically passes in the quiz context. + * + * @param string $capability the name of the capability to check. For example mod/forum:view. + * @param int|null $userid A user id. By default (null) checks the permissions of the current user. + * @param bool $doanything If false, ignore effect of admin role assignment. + */ + public function require_capability($capability, $userid = null, $doanything = true) { + $this->quizobj->require_capability($capability, $userid, $doanything); + } + + /** + * Check the appropriate capability to see whether this user may review their own attempt. + * If not, prints an error. + */ + public function check_review_capability() { + if ($this->get_attempt_state() == display_options::IMMEDIATELY_AFTER) { + $capability = 'mod/quiz:attempt'; + } else { + $capability = 'mod/quiz:reviewmyattempts'; + } + + // These next tests are in a slighly funny order. The point is that the + // common and most performance-critical case is students attempting a quiz + // so we want to check that permisison first. + + if ($this->has_capability($capability)) { + // User has the permission that lets you do the quiz as a student. Fine. + return; + } + + if ($this->has_capability('mod/quiz:viewreports') || + $this->has_capability('mod/quiz:preview')) { + // User has the permission that lets teachers review. Fine. + return; + } + + // They should not be here. Trigger the standard no-permission error + // but using the name of the student capability. + // We know this will fail. We just want the stadard exception thown. + $this->require_capability($capability); + } + + /** + * Checks whether a user may navigate to a particular slot. + * + * @param int $slot the target slot (currently does not affect the answer). + * @return bool true if the navigation should be allowed. + */ + public function can_navigate_to($slot) { + if ($this->attempt->state == self::OVERDUE) { + // When the attempt is overdue, students can only see the + // attempt summary page and cannot navigate anywhere else. + return false; + } + + switch ($this->get_navigation_method()) { + case QUIZ_NAVMETHOD_FREE: + return true; + break; + case QUIZ_NAVMETHOD_SEQ: + return false; + break; + } + return true; + } + + /** + * @return int one of the display_options::DURING, + * IMMEDIATELY_AFTER, LATER_WHILE_OPEN or AFTER_CLOSE constants. + */ + public function get_attempt_state() { + return quiz_attempt_state($this->get_quiz(), $this->attempt); + } + + /** + * Wrapper that the correct display_options for this quiz at the + * moment. + * + * @param bool $reviewing true for options when reviewing, false for when attempting. + * @return question_display_options the render options for this user on this attempt. + */ + public function get_display_options($reviewing) { + if ($reviewing) { + if (is_null($this->reviewoptions)) { + $this->reviewoptions = quiz_get_review_options($this->get_quiz(), + $this->attempt, $this->quizobj->get_context()); + if ($this->is_own_preview()) { + // It should always be possible for a teacher to review their + // own preview irrespective of the review options settings. + $this->reviewoptions->attempt = true; + } + } + return $this->reviewoptions; + + } else { + $options = display_options::make_from_quiz($this->get_quiz(), + display_options::DURING); + $options->flags = quiz_get_flag_option($this->attempt, $this->quizobj->get_context()); + return $options; + } + } + + /** + * Wrapper that the correct display_options for this quiz at the + * moment. + * + * @param bool $reviewing true for review page, else attempt page. + * @param int $slot which question is being displayed. + * @param moodle_url $thispageurl to return to after the editing form is + * submitted or cancelled. If null, no edit link will be generated. + * + * @return question_display_options the render options for this user on this + * attempt, with extra info to generate an edit link, if applicable. + */ + public function get_display_options_with_edit_link($reviewing, $slot, $thispageurl) { + $options = clone($this->get_display_options($reviewing)); + + if (!$thispageurl) { + return $options; + } + + if (!($reviewing || $this->is_preview())) { + return $options; + } + + $question = $this->quba->get_question($slot, false); + if (!question_has_capability_on($question, 'edit', $question->category)) { + return $options; + } + + $options->editquestionparams['cmid'] = $this->get_cmid(); + $options->editquestionparams['returnurl'] = $thispageurl; + + return $options; + } + + /** + * @param int $page page number + * @return bool true if this is the last page of the quiz. + */ + public function is_last_page($page) { + return $page == count($this->pagelayout) - 1; + } + + /** + * Return the list of slot numbers for either a given page of the quiz, or for the + * whole quiz. + * + * @param mixed $page string 'all' or integer page number. + * @return array the requested list of slot numbers. + */ + public function get_slots($page = 'all') { + if ($page === 'all') { + $numbers = array(); + foreach ($this->pagelayout as $numbersonpage) { + $numbers = array_merge($numbers, $numbersonpage); + } + return $numbers; + } else { + return $this->pagelayout[$page]; + } + } + + /** + * Return the list of slot numbers for either a given page of the quiz, or for the + * whole quiz. + * + * @param mixed $page string 'all' or integer page number. + * @return array the requested list of slot numbers. + */ + public function get_active_slots($page = 'all') { + $activeslots = array(); + foreach ($this->get_slots($page) as $slot) { + if (!$this->is_blocked_by_previous_question($slot)) { + $activeslots[] = $slot; + } + } + return $activeslots; + } + + /** + * Helper method for unit tests. Get the underlying question usage object. + * + * @return question_usage_by_activity the usage. + */ + public function get_question_usage() { + if (!(PHPUNIT_TEST || defined('BEHAT_TEST'))) { + throw new coding_exception('get_question_usage is only for use in unit tests. ' . + 'For other operations, use the quiz_attempt api, or extend it properly.'); + } + return $this->quba; + } + + /** + * Get the question_attempt object for a particular question in this attempt. + * + * @param int $slot the number used to identify this question within this attempt. + * @return question_attempt the requested question_attempt. + */ + public function get_question_attempt($slot) { + return $this->quba->get_question_attempt($slot); + } + + /** + * Get all the question_attempt objects that have ever appeared in a given slot. + * + * This relates to the 'Try another question like this one' feature. + * + * @param int $slot the number used to identify this question within this attempt. + * @return question_attempt[] the attempts. + */ + public function all_question_attempts_originally_in_slot($slot) { + $qas = array(); + foreach ($this->quba->get_attempt_iterator() as $qa) { + if ($qa->get_metadata('originalslot') == $slot) { + $qas[] = $qa; + } + } + $qas[] = $this->quba->get_question_attempt($slot); + return $qas; + } + + /** + * Is a particular question in this attempt a real question, or something like a description. + * + * @param int $slot the number used to identify this question within this attempt. + * @return int whether that question is a real question. Actually returns the + * question length, which could theoretically be greater than one. + */ + public function is_real_question($slot) { + return $this->quba->get_question($slot, false)->length; + } + + /** + * Is a particular question in this attempt a real question, or something like a description. + * + * @param int $slot the number used to identify this question within this attempt. + * @return bool whether that question is a real question. + */ + public function is_question_flagged($slot) { + return $this->quba->get_question_attempt($slot)->is_flagged(); + } + + /** + * Checks whether the question in this slot requires the previous + * question to have been completed. + * + * @param int $slot the number used to identify this question within this attempt. + * @return bool whether the previous question must have been completed before + * this one can be seen. + */ + public function is_blocked_by_previous_question($slot) { + return $slot > 1 && isset($this->slots[$slot]) && $this->slots[$slot]->requireprevious && + !$this->slots[$slot]->section->shufflequestions && + !$this->slots[$slot - 1]->section->shufflequestions && + $this->get_navigation_method() != QUIZ_NAVMETHOD_SEQ && + !$this->get_question_state($slot - 1)->is_finished() && + $this->quba->can_question_finish_during_attempt($slot - 1); + } + + /** + * Is it possible for this question to be re-started within this attempt? + * + * @param int $slot the number used to identify this question within this attempt. + * @return bool whether the student should be given the option to restart this question now. + */ + public function can_question_be_redone_now($slot) { + return $this->get_quiz()->canredoquestions && !$this->is_finished() && + $this->get_question_state($slot)->is_finished(); + } + + /** + * Given a slot in this attempt, which may or not be a redone question, return the original slot. + * + * @param int $slot identifies a particular question in this attempt. + * @return int the slot where this question was originally. + */ + public function get_original_slot($slot) { + $originalslot = $this->quba->get_question_attempt_metadata($slot, 'originalslot'); + if ($originalslot) { + return $originalslot; + } else { + return $slot; + } + } + + /** + * Get the displayed question number for a slot. + * + * @param int $slot the number used to identify this question within this attempt. + * @return string the displayed question number for the question in this slot. + * For example '1', '2', '3' or 'i'. + */ + public function get_question_number($slot) { + return $this->questionnumbers[$slot]; + } + + /** + * If the section heading, if any, that should come just before this slot. + * + * @param int $slot identifies a particular question in this attempt. + * @return string the required heading, or null if there is not one here. + */ + public function get_heading_before_slot($slot) { + if ($this->slots[$slot]->firstinsection) { + return $this->slots[$slot]->section->heading; + } else { + return null; + } + } + + /** + * Return the page of the quiz where this question appears. + * + * @param int $slot the number used to identify this question within this attempt. + * @return int the page of the quiz this question appears on. + */ + public function get_question_page($slot) { + return $this->questionpages[$slot]; + } + + /** + * Return the grade obtained on a particular question, if the user is permitted + * to see it. You must previously have called load_question_states to load the + * state data about this question. + * + * @param int $slot the number used to identify this question within this attempt. + * @return string the formatted grade, to the number of decimal places specified + * by the quiz. + */ + public function get_question_name($slot) { + return $this->quba->get_question($slot, false)->name; + } + + /** + * Return the {@link question_state} that this question is in. + * + * @param int $slot the number used to identify this question within this attempt. + * @return question_state the state this question is in. + */ + public function get_question_state($slot) { + return $this->quba->get_question_state($slot); + } + + /** + * Return the grade obtained on a particular question, if the user is permitted + * to see it. You must previously have called load_question_states to load the + * state data about this question. + * + * @param int $slot the number used to identify this question within this attempt. + * @param bool $showcorrectness Whether right/partial/wrong states should + * be distinguished. + * @return string the formatted grade, to the number of decimal places specified + * by the quiz. + */ + public function get_question_status($slot, $showcorrectness) { + return $this->quba->get_question_state_string($slot, $showcorrectness); + } + + /** + * Return the grade obtained on a particular question, if the user is permitted + * to see it. You must previously have called load_question_states to load the + * state data about this question. + * + * @param int $slot the number used to identify this question within this attempt. + * @param bool $showcorrectness Whether right/partial/wrong states should + * be distinguished. + * @return string class name for this state. + */ + public function get_question_state_class($slot, $showcorrectness) { + return $this->quba->get_question_state_class($slot, $showcorrectness); + } + + /** + * Return the grade obtained on a particular question. + * + * You must previously have called load_question_states to load the state + * data about this question. + * + * @param int $slot the number used to identify this question within this attempt. + * @return string the formatted grade, to the number of decimal places specified by the quiz. + */ + public function get_question_mark($slot) { + return quiz_format_question_grade($this->get_quiz(), $this->quba->get_question_mark($slot)); + } + + /** + * Get the time of the most recent action performed on a question. + * + * @param int $slot the number used to identify this question within this usage. + * @return int timestamp. + */ + public function get_question_action_time($slot) { + return $this->quba->get_question_action_time($slot); + } + + /** + * Return the question type name for a given slot within the current attempt. + * + * @param int $slot the number used to identify this question within this attempt. + * @return string the question type name. + * @since Moodle 3.1 + */ + public function get_question_type_name($slot) { + return $this->quba->get_question($slot, false)->get_type_name(); + } + + /** + * Get the time remaining for an in-progress attempt, if the time is short + * enough that it would be worth showing a timer. + * + * @param int $timenow the time to consider as 'now'. + * @return int|false the number of seconds remaining for this attempt. + * False if there is no limit. + */ + public function get_time_left_display($timenow) { + if ($this->attempt->state != self::IN_PROGRESS) { + return false; + } + return $this->get_access_manager($timenow)->get_time_left_display($this->attempt, $timenow); + } + + + /** + * @return int the time when this attempt was submitted. 0 if it has not been + * submitted yet. + */ + public function get_submitted_date() { + return $this->attempt->timefinish; + } + + /** + * If the attempt is in an applicable state, work out the time by which the + * student should next do something. + * + * @return int timestamp by which the student needs to do something. + */ + public function get_due_date() { + $deadlines = array(); + if ($this->quizobj->get_quiz()->timelimit) { + $deadlines[] = $this->attempt->timestart + $this->quizobj->get_quiz()->timelimit; + } + if ($this->quizobj->get_quiz()->timeclose) { + $deadlines[] = $this->quizobj->get_quiz()->timeclose; + } + if ($deadlines) { + $duedate = min($deadlines); + } else { + return false; + } + + switch ($this->attempt->state) { + case self::IN_PROGRESS: + return $duedate; + + case self::OVERDUE: + return $duedate + $this->quizobj->get_quiz()->graceperiod; + + default: + throw new coding_exception('Unexpected state: ' . $this->attempt->state); + } + } + + // URLs related to this attempt ============================================ + /** + * @return string quiz view url. + */ + public function view_url() { + return $this->quizobj->view_url(); + } + + /** + * Get the URL to start or continue an attempt. + * + * @param int|null $slot which question in the attempt to go to after starting (optional). + * @param int $page which page in the attempt to go to after starting. + * @return string the URL of this quiz's edit page. Needs to be POSTed to with a cmid parameter. + */ + public function start_attempt_url($slot = null, $page = -1) { + if ($page == -1 && !is_null($slot)) { + $page = $this->get_question_page($slot); + } else { + $page = 0; + } + return $this->quizobj->start_attempt_url($page); + } + + /** + * Generates the title of the attempt page. + * + * @param int $page the page number (starting with 0) in the attempt. + * @return string attempt page title. + */ + public function attempt_page_title(int $page) : string { + if ($this->get_num_pages() > 1) { + $a = new stdClass(); + $a->name = $this->get_quiz_name(); + $a->currentpage = $page + 1; + $a->totalpages = $this->get_num_pages(); + $title = get_string('attempttitlepaged', 'quiz', $a); + } else { + $title = get_string('attempttitle', 'quiz', $this->get_quiz_name()); + } + + return $title; + } + + /** + * @param int|null $slot if specified, the slot number of a specific question to link to. + * @param int $page if specified, a particular page to link to. If not given deduced + * from $slot, or goes to the first page. + * @param int $thispage if not -1, the current page. Will cause links to other things on + * this page to be output as only a fragment. + * @return string the URL to continue this attempt. + */ + public function attempt_url($slot = null, $page = -1, $thispage = -1) { + return $this->page_and_question_url('attempt', $slot, $page, false, $thispage); + } + + /** + * Generates the title of the summary page. + * + * @return string summary page title. + */ + public function summary_page_title() : string { + return get_string('attemptsummarytitle', 'quiz', $this->get_quiz_name()); + } + + /** + * @return moodle_url the URL of this quiz's summary page. + */ + public function summary_url() { + return new moodle_url('/mod/quiz/summary.php', array('attempt' => $this->attempt->id, 'cmid' => $this->get_cmid())); + } + + /** + * @return moodle_url the URL of this quiz's summary page. + */ + public function processattempt_url() { + return new moodle_url('/mod/quiz/processattempt.php'); + } + + /** + * Generates the title of the review page. + * + * @param int $page the page number (starting with 0) in the attempt. + * @param bool $showall whether the review page contains the entire attempt on one page. + * @return string title of the review page. + */ + public function review_page_title(int $page, bool $showall = false) : string { + if (!$showall && $this->get_num_pages() > 1) { + $a = new stdClass(); + $a->name = $this->get_quiz_name(); + $a->currentpage = $page + 1; + $a->totalpages = $this->get_num_pages(); + $title = get_string('attemptreviewtitlepaged', 'quiz', $a); + } else { + $title = get_string('attemptreviewtitle', 'quiz', $this->get_quiz_name()); + } + + return $title; + } + + /** + * @param int|null $slot indicates which question to link to. + * @param int $page if specified, the URL of this particular page of the attempt, otherwise + * the URL will go to the first page. If -1, deduce $page from $slot. + * @param bool|null $showall if true, the URL will be to review the entire attempt on one page, + * and $page will be ignored. If null, a sensible default will be chosen. + * @param int $thispage if not -1, the current page. Will cause links to other things on + * this page to be output as only a fragment. + * @return string the URL to review this attempt. + */ + public function review_url($slot = null, $page = -1, $showall = null, $thispage = -1) { + return $this->page_and_question_url('review', $slot, $page, $showall, $thispage); + } + + /** + * By default, should this script show all questions on one page for this attempt? + * + * @param string $script the script name, e.g. 'attempt', 'summary', 'review'. + * @return bool whether show all on one page should be on by default. + */ + public function get_default_show_all($script) { + return $script === 'review' && count($this->questionpages) < self::MAX_SLOTS_FOR_DEFAULT_REVIEW_SHOW_ALL; + } + + // Bits of content ========================================================= + + /** + * If $reviewoptions->attempt is false, meaning that students can't review this + * attempt at the moment, return an appropriate string explaining why. + * + * @param bool $short if true, return a shorter string. + * @return string an appropriate message. + */ + public function cannot_review_message($short = false) { + return $this->quizobj->cannot_review_message( + $this->get_attempt_state(), $short); + } + + /** + * Initialise the JS etc. required all the questions on a page. + * + * @param int|string $page a page number, or 'all'. + * @param bool $showall if true forces page number to all. + * @return string HTML to output - mostly obsolete, will probably be an empty string. + */ + public function get_html_head_contributions($page = 'all', $showall = false) { + if ($showall) { + $page = 'all'; + } + $result = ''; + foreach ($this->get_slots($page) as $slot) { + $result .= $this->quba->render_question_head_html($slot); + } + $result .= question_engine::initialise_js(); + return $result; + } + + /** + * Initialise the JS etc. required by one question. + * + * @param int $slot the question slot number. + * @return string HTML to output - but this is mostly obsolete. Will probably be an empty string. + */ + public function get_question_html_head_contributions($slot) { + return $this->quba->render_question_head_html($slot) . + question_engine::initialise_js(); + } + + /** + * Print the HTML for the start new preview button, if the current user + * is allowed to see one. + * + * @return string HTML for the button. + */ + public function restart_preview_button() { + global $OUTPUT; + if ($this->is_preview() && $this->is_preview_user()) { + return $OUTPUT->single_button(new moodle_url( + $this->start_attempt_url(), array('forcenew' => true)), + get_string('startnewpreview', 'quiz')); + } else { + return ''; + } + } + + /** + * Generate the HTML that displayes the question in its current state, with + * the appropriate display options. + * + * @param int $slot identifies the question in the attempt. + * @param bool $reviewing is the being printed on an attempt or a review page. + * @param renderer $renderer the quiz renderer. + * @param moodle_url $thispageurl the URL of the page this question is being printed on. + * @return string HTML for the question in its current state. + */ + public function render_question($slot, $reviewing, renderer $renderer, $thispageurl = null) { + if ($this->is_blocked_by_previous_question($slot)) { + $placeholderqa = $this->make_blocked_question_placeholder($slot); + + $displayoptions = $this->get_display_options($reviewing); + $displayoptions->manualcomment = question_display_options::HIDDEN; + $displayoptions->history = question_display_options::HIDDEN; + $displayoptions->readonly = true; + + return html_writer::div($placeholderqa->render($displayoptions, + $this->get_question_number($this->get_original_slot($slot))), + 'mod_quiz-blocked_question_warning'); + } + + return $this->render_question_helper($slot, $reviewing, $thispageurl, $renderer, null); + } + + /** + * Helper used by {@link render_question()} and {@link render_question_at_step()}. + * + * @param int $slot identifies the question in the attempt. + * @param bool $reviewing is the being printed on an attempt or a review page. + * @param moodle_url $thispageurl the URL of the page this question is being printed on. + * @param renderer $renderer the quiz renderer. + * @param int|null $seq the seq number of the past state to display. + * @return string HTML fragment. + */ + protected function render_question_helper($slot, $reviewing, $thispageurl, + renderer $renderer, $seq) { + $originalslot = $this->get_original_slot($slot); + $number = $this->get_question_number($originalslot); + $displayoptions = $this->get_display_options_with_edit_link($reviewing, $slot, $thispageurl); + + if ($slot != $originalslot) { + $originalmaxmark = $this->get_question_attempt($slot)->get_max_mark(); + $this->get_question_attempt($slot)->set_max_mark($this->get_question_attempt($originalslot)->get_max_mark()); + } + + if ($this->can_question_be_redone_now($slot)) { + $displayoptions->extrainfocontent = $renderer->redo_question_button( + $slot, $displayoptions->readonly); + } + + if ($displayoptions->history && $displayoptions->questionreviewlink) { + $links = $this->links_to_other_redos($slot, $displayoptions->questionreviewlink); + if ($links) { + $displayoptions->extrahistorycontent = html_writer::tag('p', + get_string('redoesofthisquestion', 'quiz', $renderer->render($links))); + } + } + + if ($seq === null) { + $output = $this->quba->render_question($slot, $displayoptions, $number); + } else { + $output = $this->quba->render_question_at_step($slot, $seq, $displayoptions, $number); + } + + if ($slot != $originalslot) { + $this->get_question_attempt($slot)->set_max_mark($originalmaxmark); + } + + return $output; + } + + /** + * Create a fake question to be displayed in place of a question that is blocked + * until the previous question has been answered. + * + * @param int $slot int slot number of the question to replace. + * @return question_attempt the placeholder question attempt. + */ + protected function make_blocked_question_placeholder($slot) { + $replacedquestion = $this->get_question_attempt($slot)->get_question(false); + + question_bank::load_question_definition_classes('description'); + $question = new qtype_description_question(); + $question->id = $replacedquestion->id; + $question->category = null; + $question->parent = 0; + $question->qtype = question_bank::get_qtype('description'); + $question->name = ''; + $question->questiontext = get_string('questiondependsonprevious', 'quiz'); + $question->questiontextformat = FORMAT_HTML; + $question->generalfeedback = ''; + $question->defaultmark = $this->quba->get_question_max_mark($slot); + $question->length = $replacedquestion->length; + $question->penalty = 0; + $question->stamp = ''; + $question->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY; + $question->timecreated = null; + $question->timemodified = null; + $question->createdby = null; + $question->modifiedby = null; + + $placeholderqa = new question_attempt($question, $this->quba->get_id(), + null, $this->quba->get_question_max_mark($slot)); + $placeholderqa->set_slot($slot); + $placeholderqa->start($this->get_quiz()->preferredbehaviour, 1); + $placeholderqa->set_flagged($this->is_question_flagged($slot)); + return $placeholderqa; + } + + /** + * Like {@link render_question()} but displays the question at the past step + * indicated by $seq, rather than showing the latest step. + * + * @param int $slot the slot number of a question in this quiz attempt. + * @param int $seq the seq number of the past state to display. + * @param bool $reviewing is the being printed on an attempt or a review page. + * @param renderer $renderer the quiz renderer. + * @param moodle_url $thispageurl the URL of the page this question is being printed on. + * @return string HTML for the question in its current state. + */ + public function render_question_at_step($slot, $seq, $reviewing, + renderer $renderer, $thispageurl = null) { + return $this->render_question_helper($slot, $reviewing, $thispageurl, $renderer, $seq); + } + + /** + * Wrapper round print_question from lib/questionlib.php. + * + * @param int $slot the id of a question in this quiz attempt. + * @return string HTML of the question. + */ + public function render_question_for_commenting($slot) { + $options = $this->get_display_options(true); + $options->generalfeedback = question_display_options::HIDDEN; + $options->manualcomment = question_display_options::EDITABLE; + return $this->quba->render_question($slot, $options, + $this->get_question_number($slot)); + } + + /** + * Check wheter access should be allowed to a particular file. + * + * @param int $slot the slot of a question in this quiz attempt. + * @param bool $reviewing is the being printed on an attempt or a review page. + * @param int $contextid the file context id from the request. + * @param string $component the file component from the request. + * @param string $filearea the file area from the request. + * @param array $args extra part components from the request. + * @param bool $forcedownload whether to force download. + * @return string HTML for the question in its current state. + */ + public function check_file_access($slot, $reviewing, $contextid, $component, + $filearea, $args, $forcedownload) { + $options = $this->get_display_options($reviewing); + + // Check permissions - warning there is similar code in review.php and + // reviewquestion.php. If you change on, change them all. + if ($reviewing && $this->is_own_attempt() && !$options->attempt) { + return false; + } + + if ($reviewing && !$this->is_own_attempt() && !$this->is_review_allowed()) { + return false; + } + + return $this->quba->check_file_access($slot, $options, + $component, $filearea, $args, $forcedownload); + } + + /** + * Get the navigation panel object for this attempt. + * + * @param renderer $output the quiz renderer to use to output things. + * @param string $panelclass The type of panel, navigation_panel_attempt::class or navigation_panel_review::class + * @param int $page the current page number. + * @param bool $showall whether we are showing the whole quiz on one page. (Used by review.php.) + * @return block_contents the requested object. + */ + public function get_navigation_panel(renderer $output, + $panelclass, $page, $showall = false) { + $panel = new $panelclass($this, $this->get_display_options(true), $page, $showall); + + $bc = new block_contents(); + $bc->attributes['id'] = 'mod_quiz_navblock'; + $bc->attributes['role'] = 'navigation'; + $bc->title = get_string('quiznavigation', 'quiz'); + $bc->content = $output->navigation_panel($panel); + return $bc; + } + + /** + * Return an array of variant URLs to other attempts at this quiz. + * + * The $url passed in must contain an attempt parameter. + * + * The {@see links_to_other_attempts} object returned contains an + * array with keys that are the attempt number, 1, 2, 3. + * The array values are either a {@link moodle_url} with the attempt parameter + * updated to point to the attempt id of the other attempt, or null corresponding + * to the current attempt number. + * + * @param moodle_url $url a URL. + * @return links_to_other_attempts|bool containing array int => null|moodle_url. + * False if none. + */ + public function links_to_other_attempts(moodle_url $url) { + $attempts = quiz_get_user_attempts($this->get_quiz()->id, $this->attempt->userid, 'all'); + if (count($attempts) <= 1) { + return false; + } + + $links = new links_to_other_attempts(); + foreach ($attempts as $at) { + if ($at->id == $this->attempt->id) { + $links->links[$at->attempt] = null; + } else { + $links->links[$at->attempt] = new moodle_url($url, array('attempt' => $at->id)); + } + } + return $links; + } + + /** + * Return an array of variant URLs to other redos of the question in a particular slot. + * + * The $url passed in must contain a slot parameter. + * + * The {@see links_to_other_attempts} object returned contains an + * array with keys that are the redo number, 1, 2, 3. + * The array values are either a {@see moodle_url} with the slot parameter + * updated to point to the slot that has that redo of this question; or null + * corresponding to the redo identified by $slot. + * + * @param int $slot identifies a question in this attempt. + * @param moodle_url $baseurl the base URL to modify to generate each link. + * @return links_to_other_attempts|null containing array int => null|moodle_url, + * or null if the question in this slot has not been redone. + */ + public function links_to_other_redos($slot, moodle_url $baseurl) { + $originalslot = $this->get_original_slot($slot); + + $qas = $this->all_question_attempts_originally_in_slot($originalslot); + if (count($qas) <= 1) { + return null; + } + + $links = new links_to_other_attempts(); + $index = 1; + foreach ($qas as $qa) { + if ($qa->get_slot() == $slot) { + $links->links[$index] = null; + } else { + $url = new moodle_url($baseurl, array('slot' => $qa->get_slot())); + $links->links[$index] = new action_link($url, $index, + new popup_action('click', $url, 'reviewquestion', + array('width' => 450, 'height' => 650)), + array('title' => get_string('reviewresponse', 'question'))); + } + $index++; + } + return $links; + } + + // Methods for processing ================================================== + + /** + * Check this attempt, to see if there are any state transitions that should + * happen automatically. This function will update the attempt checkstatetime. + * @param int $timestamp the timestamp that should be stored as the modified + * @param bool $studentisonline is the student currently interacting with Moodle? + */ + public function handle_if_time_expired($timestamp, $studentisonline) { + + $timeclose = $this->get_access_manager($timestamp)->get_end_time($this->attempt); + + if ($timeclose === false || $this->is_preview()) { + $this->update_timecheckstate(null); + return; // No time limit. + } + if ($timestamp < $timeclose) { + $this->update_timecheckstate($timeclose); + return; // Time has not yet expired. + } + + // If the attempt is already overdue, look to see if it should be abandoned ... + if ($this->attempt->state == self::OVERDUE) { + $timeoverdue = $timestamp - $timeclose; + $graceperiod = $this->quizobj->get_quiz()->graceperiod; + if ($timeoverdue >= $graceperiod) { + $this->process_abandon($timestamp, $studentisonline); + } else { + // Overdue time has not yet expired + $this->update_timecheckstate($timeclose + $graceperiod); + } + return; // ... and we are done. + } + + if ($this->attempt->state != self::IN_PROGRESS) { + $this->update_timecheckstate(null); + return; // Attempt is already in a final state. + } + + // Otherwise, we were in quiz_attempt::IN_PROGRESS, and time has now expired. + // Transition to the appropriate state. + switch ($this->quizobj->get_quiz()->overduehandling) { + case 'autosubmit': + $this->process_finish($timestamp, false, $studentisonline ? $timestamp : $timeclose, $studentisonline); + return; + + case 'graceperiod': + $this->process_going_overdue($timestamp, $studentisonline); + return; + + case 'autoabandon': + $this->process_abandon($timestamp, $studentisonline); + return; + } + + // This is an overdue attempt with no overdue handling defined, so just abandon. + $this->process_abandon($timestamp, $studentisonline); + return; + } + + /** + * Process all the actions that were submitted as part of the current request. + * + * @param int $timestamp the timestamp that should be stored as the modified. + * time in the database for these actions. If null, will use the current time. + * @param bool $becomingoverdue + * @param array|null $simulatedresponses If not null, then we are testing, and this is an array of simulated data. + * There are two formats supported here, for historical reasons. The newer approach is to pass an array created by + * {@link core_question_generator::get_simulated_post_data_for_questions_in_usage()}. + * the second is to pass an array slot no => contains arrays representing student + * responses which will be passed to {@link question_definition::prepare_simulated_post_data()}. + * This second method will probably get deprecated one day. + */ + public function process_submitted_actions($timestamp, $becomingoverdue = false, $simulatedresponses = null) { + global $DB; + + $transaction = $DB->start_delegated_transaction(); + + if ($simulatedresponses !== null) { + if (is_int(key($simulatedresponses))) { + // Legacy approach. Should be removed one day. + $simulatedpostdata = $this->quba->prepare_simulated_post_data($simulatedresponses); + } else { + $simulatedpostdata = $simulatedresponses; + } + } else { + $simulatedpostdata = null; + } + + $this->quba->process_all_actions($timestamp, $simulatedpostdata); + question_engine::save_questions_usage_by_activity($this->quba); + + $this->attempt->timemodified = $timestamp; + if ($this->attempt->state == self::FINISHED) { + $this->attempt->sumgrades = $this->quba->get_total_mark(); + } + if ($becomingoverdue) { + $this->process_going_overdue($timestamp, true); + } else { + $DB->update_record('quiz_attempts', $this->attempt); + } + + if (!$this->is_preview() && $this->attempt->state == self::FINISHED) { + quiz_save_best_grade($this->get_quiz(), $this->get_userid()); + } + + $transaction->allow_commit(); + } + + /** + * Replace a question in an attempt with a new attempt at the same question. + * + * Well, for randomised questions, it won't be the same question, it will be + * a different randomised selection. + * + * @param int $slot the question to restart. + * @param int $timestamp the timestamp to record for this action. + */ + public function process_redo_question($slot, $timestamp) { + global $DB; + + if (!$this->can_question_be_redone_now($slot)) { + throw new coding_exception('Attempt to restart the question in slot ' . $slot . + ' when it is not in a state to be restarted.'); + } + + $qubaids = new \mod_quiz\question\qubaids_for_users_attempts( + $this->get_quizid(), $this->get_userid(), 'all', true); + + $transaction = $DB->start_delegated_transaction(); + + // Add the question to the usage. It is important we do this before we choose a variant. + $newquestionid = qbank_helper::choose_question_for_redo($this->get_quizid(), + $this->get_quizobj()->get_context(), $this->slots[$slot]->id, $qubaids); + $newquestion = question_bank::load_question($newquestionid, $this->get_quiz()->shuffleanswers); + $newslot = $this->quba->add_question_in_place_of_other($slot, $newquestion); + + // Choose the variant. + if ($newquestion->get_num_variants() == 1) { + $variant = 1; + } else { + $variantstrategy = new core_question\engine\variants\least_used_strategy( + $this->quba, $qubaids); + $variant = $variantstrategy->choose_variant($newquestion->get_num_variants(), + $newquestion->get_variants_selection_seed()); + } + + // Start the question. + $this->quba->start_question($slot, $variant); + $this->quba->set_max_mark($newslot, 0); + $this->quba->set_question_attempt_metadata($newslot, 'originalslot', $slot); + question_engine::save_questions_usage_by_activity($this->quba); + $this->fire_attempt_question_restarted_event($slot, $newquestion->id); + + $transaction->allow_commit(); + } + + /** + * Process all the autosaved data that was part of the current request. + * + * @param int $timestamp the timestamp that should be stored as the modified. + * time in the database for these actions. If null, will use the current time. + */ + public function process_auto_save($timestamp) { + global $DB; + + $transaction = $DB->start_delegated_transaction(); + + $this->quba->process_all_autosaves($timestamp); + question_engine::save_questions_usage_by_activity($this->quba); + $this->fire_attempt_autosaved_event(); + + $transaction->allow_commit(); + } + + /** + * Update the flagged state for all question_attempts in this usage, if their + * flagged state was changed in the request. + */ + public function save_question_flags() { + global $DB; + + $transaction = $DB->start_delegated_transaction(); + $this->quba->update_question_flags(); + question_engine::save_questions_usage_by_activity($this->quba); + $transaction->allow_commit(); + } + + /** + * Submit the attempt. + * + * The separate $timefinish argument should be used when the quiz attempt + * is being processed asynchronously (for example when cron is submitting + * attempts where the time has expired). + * + * @param int $timestamp the time to record as last modified time. + * @param bool $processsubmitted if true, and question responses in the current + * POST request are stored to be graded, before the attempt is finished. + * @param ?int $timefinish if set, use this as the finish time for the attempt. + * (otherwise use $timestamp as the finish time as well). + * @param bool $studentisonline is the student currently interacting with Moodle? + */ + public function process_finish($timestamp, $processsubmitted, $timefinish = null, $studentisonline = false) { + global $DB; + + $transaction = $DB->start_delegated_transaction(); + + if ($processsubmitted) { + $this->quba->process_all_actions($timestamp); + } + $this->quba->finish_all_questions($timestamp); + + question_engine::save_questions_usage_by_activity($this->quba); + + $this->attempt->timemodified = $timestamp; + $this->attempt->timefinish = $timefinish ?? $timestamp; + $this->attempt->sumgrades = $this->quba->get_total_mark(); + $this->attempt->state = self::FINISHED; + $this->attempt->timecheckstate = null; + $this->attempt->gradednotificationsenttime = null; + + if (!$this->requires_manual_grading() || + !has_capability('mod/quiz:emailnotifyattemptgraded', $this->get_quizobj()->get_context(), + $this->get_userid())) { + $this->attempt->gradednotificationsenttime = $this->attempt->timefinish; + } + + $DB->update_record('quiz_attempts', $this->attempt); + + if (!$this->is_preview()) { + quiz_save_best_grade($this->get_quiz(), $this->attempt->userid); + + // Trigger event. + $this->fire_state_transition_event('\mod_quiz\event\attempt_submitted', $timestamp, $studentisonline); + + // Tell any access rules that care that the attempt is over. + $this->get_access_manager($timestamp)->current_attempt_finished(); + } + + $transaction->allow_commit(); + } + + /** + * Update this attempt timecheckstate if necessary. + * + * @param int|null $time the timestamp to set. + */ + public function update_timecheckstate($time) { + global $DB; + if ($this->attempt->timecheckstate !== $time) { + $this->attempt->timecheckstate = $time; + $DB->set_field('quiz_attempts', 'timecheckstate', $time, array('id' => $this->attempt->id)); + } + } + + /** + * Mark this attempt as now overdue. + * + * @param int $timestamp the time to deem as now. + * @param bool $studentisonline is the student currently interacting with Moodle? + */ + public function process_going_overdue($timestamp, $studentisonline) { + global $DB; + + $transaction = $DB->start_delegated_transaction(); + $this->attempt->timemodified = $timestamp; + $this->attempt->state = self::OVERDUE; + // If we knew the attempt close time, we could compute when the graceperiod ends. + // Instead we'll just fix it up through cron. + $this->attempt->timecheckstate = $timestamp; + $DB->update_record('quiz_attempts', $this->attempt); + + $this->fire_state_transition_event('\mod_quiz\event\attempt_becameoverdue', $timestamp, $studentisonline); + + $transaction->allow_commit(); + + quiz_send_overdue_message($this); + } + + /** + * Mark this attempt as abandoned. + * + * @param int $timestamp the time to deem as now. + * @param bool $studentisonline is the student currently interacting with Moodle? + */ + public function process_abandon($timestamp, $studentisonline) { + global $DB; + + $transaction = $DB->start_delegated_transaction(); + $this->attempt->timemodified = $timestamp; + $this->attempt->state = self::ABANDONED; + $this->attempt->timecheckstate = null; + $DB->update_record('quiz_attempts', $this->attempt); + + $this->fire_state_transition_event('\mod_quiz\event\attempt_abandoned', $timestamp, $studentisonline); + + $transaction->allow_commit(); + } + + /** + * Fire a state transition event. + * + * @param string $eventclass the event class name. + * @param int $timestamp the timestamp to include in the event. + * @param bool $studentisonline is the student currently interacting with Moodle? + */ + protected function fire_state_transition_event($eventclass, $timestamp, $studentisonline) { + global $USER; + $quizrecord = $this->get_quiz(); + $params = array( + 'context' => $this->get_quizobj()->get_context(), + 'courseid' => $this->get_courseid(), + 'objectid' => $this->attempt->id, + 'relateduserid' => $this->attempt->userid, + 'other' => array( + 'submitterid' => CLI_SCRIPT ? null : $USER->id, + 'quizid' => $quizrecord->id, + 'studentisonline' => $studentisonline + ) + ); + $event = $eventclass::create($params); + $event->add_record_snapshot('quiz', $this->get_quiz()); + $event->add_record_snapshot('quiz_attempts', $this->get_attempt()); + $event->trigger(); + } + + // Private methods ========================================================= + + /** + * Get a URL for a particular question on a particular page of the quiz. + * Used by {@link attempt_url()} and {@link review_url()}. + * + * @param string $script. Used in the URL like /mod/quiz/$script.php. + * @param int $slot identifies the specific question on the page to jump to. + * 0 to just use the $page parameter. + * @param int $page -1 to look up the page number from the slot, otherwise + * the page number to go to. + * @param bool|null $showall if true, return a URL with showall=1, and not page number. + * if null, then an intelligent default will be chosen. + * @param int $thispage the page we are currently on. Links to questions on this + * page will just be a fragment #q123. -1 to disable this. + * @return moodle_url The requested URL. + */ + protected function page_and_question_url($script, $slot, $page, $showall, $thispage) { + + $defaultshowall = $this->get_default_show_all($script); + if ($showall === null && ($page == 0 || $page == -1)) { + $showall = $defaultshowall; + } + + // Fix up $page. + if ($page == -1) { + if ($slot !== null && !$showall) { + $page = $this->get_question_page($slot); + } else { + $page = 0; + } + } + + if ($showall) { + $page = 0; + } + + // Add a fragment to scroll down to the question. + $fragment = ''; + if ($slot !== null) { + if ($slot == reset($this->pagelayout[$page]) && $thispage != $page) { + // Changing the page, go to top. + $fragment = '#'; + } else { + // Link to the question container. + $qa = $this->get_question_attempt($slot); + $fragment = '#' . $qa->get_outer_question_div_unique_id(); + } + } + + // Work out the correct start to the URL. + if ($thispage == $page) { + return new moodle_url($fragment); + + } else { + $url = new moodle_url('/mod/quiz/' . $script . '.php' . $fragment, + array('attempt' => $this->attempt->id, 'cmid' => $this->get_cmid())); + if ($page == 0 && $showall != $defaultshowall) { + $url->param('showall', (int) $showall); + } else if ($page > 0) { + $url->param('page', $page); + } + return $url; + } + } + + /** + * Process responses during an attempt at a quiz. + * + * @param int $timenow time when the processing started. + * @param bool $finishattempt whether to finish the attempt or not. + * @param bool $timeup true if form was submitted by timer. + * @param int $thispage current page number. + * @return string the attempt state once the data has been processed. + * @since Moodle 3.1 + */ + public function process_attempt($timenow, $finishattempt, $timeup, $thispage) { + global $DB; + + $transaction = $DB->start_delegated_transaction(); + + // Get key times. + $accessmanager = $this->get_access_manager($timenow); + $timeclose = $accessmanager->get_end_time($this->get_attempt()); + $graceperiodmin = get_config('quiz', 'graceperiodmin'); + + // Don't enforce timeclose for previews. + if ($this->is_preview()) { + $timeclose = false; + } + + // Check where we are in relation to the end time, if there is one. + $toolate = false; + if ($timeclose !== false) { + if ($timenow > $timeclose - QUIZ_MIN_TIME_TO_CONTINUE) { + // If there is only a very small amount of time left, there is no point trying + // to show the student another page of the quiz. Just finish now. + $timeup = true; + if ($timenow > $timeclose + $graceperiodmin) { + $toolate = true; + } + } else { + // If time is not close to expiring, then ignore the client-side timer's opinion + // about whether time has expired. This can happen if the time limit has changed + // since the student's previous interaction. + $timeup = false; + } + } + + // If time is running out, trigger the appropriate action. + $becomingoverdue = false; + $becomingabandoned = false; + if ($timeup) { + if ($this->get_quiz()->overduehandling === 'graceperiod') { + if ($timenow > $timeclose + $this->get_quiz()->graceperiod + $graceperiodmin) { + // Grace period has run out. + $finishattempt = true; + $becomingabandoned = true; + } else { + $becomingoverdue = true; + } + } else { + $finishattempt = true; + } + } + + if (!$finishattempt) { + // Just process the responses for this page and go to the next page. + if (!$toolate) { + try { + $this->process_submitted_actions($timenow, $becomingoverdue); + $this->fire_attempt_updated_event(); + } catch (question_out_of_sequence_exception $e) { + throw new moodle_exception('submissionoutofsequencefriendlymessage', 'question', + $this->attempt_url(null, $thispage)); + + } catch (Exception $e) { + // This sucks, if we display our own custom error message, there is no way + // to display the original stack trace. + $debuginfo = ''; + if (!empty($e->debuginfo)) { + $debuginfo = $e->debuginfo; + } + throw new moodle_exception('errorprocessingresponses', 'question', + $this->attempt_url(null, $thispage), $e->getMessage(), $debuginfo); + } + + if (!$becomingoverdue) { + foreach ($this->get_slots() as $slot) { + if (optional_param('redoslot' . $slot, false, PARAM_BOOL)) { + $this->process_redo_question($slot, $timenow); + } + } + } + + } else { + // The student is too late. + $this->process_going_overdue($timenow, true); + } + + $transaction->allow_commit(); + + return $becomingoverdue ? self::OVERDUE : self::IN_PROGRESS; + } + + // Update the quiz attempt record. + try { + if ($becomingabandoned) { + $this->process_abandon($timenow, true); + } else { + if (!$toolate || $this->get_quiz()->overduehandling === 'graceperiod') { + // Normally, we record the accurate finish time when the student is online. + $finishtime = $timenow; + } else { + // But, if there is no grade period, and the final responses were too + // late to be processed, record the close time, to reduce confusion. + $finishtime = $timeclose; + } + $this->process_finish($timenow, !$toolate, $finishtime, true); + } + + } catch (question_out_of_sequence_exception $e) { + throw new moodle_exception('submissionoutofsequencefriendlymessage', 'question', + $this->attempt_url(null, $thispage)); + + } catch (Exception $e) { + // This sucks, if we display our own custom error message, there is no way + // to display the original stack trace. + $debuginfo = ''; + if (!empty($e->debuginfo)) { + $debuginfo = $e->debuginfo; + } + throw new moodle_exception('errorprocessingresponses', 'question', + $this->attempt_url(null, $thispage), $e->getMessage(), $debuginfo); + } + + // Send the user to the review page. + $transaction->allow_commit(); + + return $becomingabandoned ? self::ABANDONED : self::FINISHED; + } + + /** + * Check a page read access to see if is an out of sequence access. + * + * If allownext is set then we also check whether access to the page + * after the current one should be permitted. + * + * @param int $page page number. + * @param bool $allownext in case of a sequential navigation, can we go to next page ? + * @return boolean false is an out of sequence access, true otherwise. + * @since Moodle 3.1 + */ + public function check_page_access(int $page, bool $allownext = true): bool { + if ($this->get_navigation_method() != QUIZ_NAVMETHOD_SEQ) { + return true; + } + // Sequential access: allow access to the summary, current page or next page. + // Or if the user review his/her attempt, see MDLQA-1523. + return $page == -1 + || $page == $this->get_currentpage() + || $allownext && ($page == $this->get_currentpage() + 1); + } + + /** + * Update attempt page. + * + * @param int $page page number. + * @return boolean true if everything was ok, false otherwise (out of sequence access). + * @since Moodle 3.1 + */ + public function set_currentpage($page) { + global $DB; + + if ($this->check_page_access($page)) { + $DB->set_field('quiz_attempts', 'currentpage', $page, array('id' => $this->get_attemptid())); + return true; + } + return false; + } + + /** + * Trigger the attempt_viewed event. + * + * @since Moodle 3.1 + */ + public function fire_attempt_viewed_event() { + $params = array( + 'objectid' => $this->get_attemptid(), + 'relateduserid' => $this->get_userid(), + 'courseid' => $this->get_courseid(), + 'context' => context_module::instance($this->get_cmid()), + 'other' => array( + 'quizid' => $this->get_quizid(), + 'page' => $this->get_currentpage() + ) + ); + $event = \mod_quiz\event\attempt_viewed::create($params); + $event->add_record_snapshot('quiz_attempts', $this->get_attempt()); + $event->trigger(); + } + + /** + * Trigger the attempt_updated event. + * + * @return void + */ + public function fire_attempt_updated_event(): void { + $params = [ + 'objectid' => $this->get_attemptid(), + 'relateduserid' => $this->get_userid(), + 'courseid' => $this->get_courseid(), + 'context' => context_module::instance($this->get_cmid()), + 'other' => [ + 'quizid' => $this->get_quizid(), + 'page' => $this->get_currentpage() + ] + ]; + $event = \mod_quiz\event\attempt_updated::create($params); + $event->add_record_snapshot('quiz_attempts', $this->get_attempt()); + $event->trigger(); + } + + /** + * Trigger the attempt_autosaved event. + * + * @return void + */ + public function fire_attempt_autosaved_event(): void { + $params = [ + 'objectid' => $this->get_attemptid(), + 'relateduserid' => $this->get_userid(), + 'courseid' => $this->get_courseid(), + 'context' => context_module::instance($this->get_cmid()), + 'other' => [ + 'quizid' => $this->get_quizid(), + 'page' => $this->get_currentpage() + ] + ]; + $event = \mod_quiz\event\attempt_autosaved::create($params); + $event->add_record_snapshot('quiz_attempts', $this->get_attempt()); + $event->trigger(); + } + + /** + * Trigger the attempt_question_restarted event. + * + * @param int $slot Slot number + * @param int $newquestionid New question id. + * @return void + */ + public function fire_attempt_question_restarted_event(int $slot, int $newquestionid): void { + $params = [ + 'objectid' => $this->get_attemptid(), + 'relateduserid' => $this->get_userid(), + 'courseid' => $this->get_courseid(), + 'context' => context_module::instance($this->get_cmid()), + 'other' => [ + 'quizid' => $this->get_quizid(), + 'page' => $this->get_currentpage(), + 'slot' => $slot, + 'newquestionid' => $newquestionid + ] + ]; + $event = \mod_quiz\event\attempt_question_restarted::create($params); + $event->add_record_snapshot('quiz_attempts', $this->get_attempt()); + $event->trigger(); + } + + /** + * Trigger the attempt_summary_viewed event. + * + * @since Moodle 3.1 + */ + public function fire_attempt_summary_viewed_event() { + + $params = array( + 'objectid' => $this->get_attemptid(), + 'relateduserid' => $this->get_userid(), + 'courseid' => $this->get_courseid(), + 'context' => context_module::instance($this->get_cmid()), + 'other' => array( + 'quizid' => $this->get_quizid() + ) + ); + $event = \mod_quiz\event\attempt_summary_viewed::create($params); + $event->add_record_snapshot('quiz_attempts', $this->get_attempt()); + $event->trigger(); + } + + /** + * Trigger the attempt_reviewed event. + * + * @since Moodle 3.1 + */ + public function fire_attempt_reviewed_event() { + + $params = array( + 'objectid' => $this->get_attemptid(), + 'relateduserid' => $this->get_userid(), + 'courseid' => $this->get_courseid(), + 'context' => context_module::instance($this->get_cmid()), + 'other' => array( + 'quizid' => $this->get_quizid() + ) + ); + $event = \mod_quiz\event\attempt_reviewed::create($params); + $event->add_record_snapshot('quiz_attempts', $this->get_attempt()); + $event->trigger(); + } + + /** + * Trigger the attempt manual grading completed event. + */ + public function fire_attempt_manual_grading_completed_event() { + $params = [ + 'objectid' => $this->get_attemptid(), + 'relateduserid' => $this->get_userid(), + 'courseid' => $this->get_courseid(), + 'context' => context_module::instance($this->get_cmid()), + 'other' => [ + 'quizid' => $this->get_quizid() + ] + ]; + + $event = \mod_quiz\event\attempt_manual_grading_completed::create($params); + $event->add_record_snapshot('quiz_attempts', $this->get_attempt()); + $event->trigger(); + } + + /** + * Update the timemodifiedoffline attempt field. + * + * This function should be used only when web services are being used. + * + * @param int $time time stamp. + * @return boolean false if the field is not updated because web services aren't being used. + * @since Moodle 3.2 + */ + public function set_offline_modified_time($time) { + // Update the timemodifiedoffline field only if web services are being used. + if (WS_SERVER) { + $this->attempt->timemodifiedoffline = $time; + return true; + } + return false; + } + + /** + * Get the total number of unanswered questions in the attempt. + * + * @return int + */ + public function get_number_of_unanswered_questions(): int { + $totalunanswered = 0; + foreach ($this->get_slots() as $slot) { + $questionstate = $this->get_question_state($slot); + if ($questionstate == question_state::$todo || $questionstate == question_state::$invalid) { + $totalunanswered++; + } + } + return $totalunanswered; + } +} diff --git a/mod/quiz/classes/task/quiz_notify_attempt_manual_grading_completed.php b/mod/quiz/classes/task/quiz_notify_attempt_manual_grading_completed.php index 445cdd102b9..62531792b45 100644 --- a/mod/quiz/classes/task/quiz_notify_attempt_manual_grading_completed.php +++ b/mod/quiz/classes/task/quiz_notify_attempt_manual_grading_completed.php @@ -20,10 +20,10 @@ defined('MOODLE_INTERNAL') || die(); use context_course; use core_user; +use mod_quiz\quiz_attempt; use moodle_recordset; use question_display_options; use mod_quiz\question\display_options; -use quiz_attempt; require_once($CFG->dirroot . '/mod/quiz/locallib.php'); diff --git a/mod/quiz/classes/task/update_overdue_attempts.php b/mod/quiz/classes/task/update_overdue_attempts.php index 8241243466d..e83c08f344e 100644 --- a/mod/quiz/classes/task/update_overdue_attempts.php +++ b/mod/quiz/classes/task/update_overdue_attempts.php @@ -24,9 +24,9 @@ */ namespace mod_quiz\task; +use mod_quiz\quiz_attempt; use moodle_exception; use moodle_recordset; -use quiz_attempt; defined('MOODLE_INTERNAL') || die(); diff --git a/mod/quiz/db/renamedclasses.php b/mod/quiz/db/renamedclasses.php index 7fac769cd1b..059eadccb9f 100644 --- a/mod/quiz/db/renamedclasses.php +++ b/mod/quiz/db/renamedclasses.php @@ -64,4 +64,5 @@ $renamedclasses = [ 'quiz_nav_panel_base' => 'mod_quiz\output\navigation_panel_base', 'quiz_attempt_nav_panel' => 'mod_quiz\output\navigation_panel_attempt', 'quiz_review_nav_panel' => 'mod_quiz\output\navigation_panel_review', + 'quiz_attempt' => 'mod_quiz\quiz_attempt', ]; diff --git a/mod/quiz/grade.php b/mod/quiz/grade.php index 970aa6f5543..c7889735261 100644 --- a/mod/quiz/grade.php +++ b/mod/quiz/grade.php @@ -24,6 +24,7 @@ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ +use mod_quiz\quiz_attempt; require_once(__DIR__ . '/../../config.php'); require_once($CFG->dirroot . '/mod/quiz/locallib.php'); diff --git a/mod/quiz/lib.php b/mod/quiz/lib.php index e90cd1fed3d..dc1c46fdb3a 100644 --- a/mod/quiz/lib.php +++ b/mod/quiz/lib.php @@ -35,6 +35,7 @@ use mod_quiz\question\display_options; use mod_quiz\question\qubaids_for_quiz; use mod_quiz\question\qubaids_for_users_attempts; use core_question\statistics\questions\all_calculated_for_qubaid_condition; +use mod_quiz\quiz_attempt; require_once($CFG->dirroot . '/calendar/lib.php'); require_once($CFG->dirroot . '/mod/quiz/attemptlib.php'); @@ -583,12 +584,7 @@ function quiz_user_complete($course, $user, $mod, $quiz) { * array if there are none. */ function quiz_get_user_attempts($quizids, $userid, $status = 'finished', $includepreviews = false) { - global $DB, $CFG; - // TODO MDL-33071 it is very annoying to have to included all of locallib.php - // just to get the quiz_attempt::FINISHED constants, but I will try to sort - // that out properly for Moodle 2.4. For now, I will just do a quick fix for - // MDL-33048. - require_once($CFG->dirroot . '/mod/quiz/locallib.php'); + global $DB; $params = array(); switch ($status) { diff --git a/mod/quiz/locallib.php b/mod/quiz/locallib.php index ec9adb793b5..a31e243b389 100644 --- a/mod/quiz/locallib.php +++ b/mod/quiz/locallib.php @@ -39,6 +39,7 @@ require_once($CFG->libdir . '/questionlib.php'); use mod_quiz\access_manager; use mod_quiz\question\bank\qbank_helper; use mod_quiz\question\display_options; +use mod_quiz\quiz_attempt; /** * @var int We show the countdown timer if there is less than this amount of time left before the @@ -80,7 +81,7 @@ define('QUIZ_SHOWIMAGE_LARGE', 2); * * @param object $quizobj the quiz object to create an attempt for. * @param int $attemptnumber the sequence number for the attempt. - * @param stdClass|null $lastattempt the previous attempt by this user, if any. Only needed + * @param stdClass|false $lastattempt the previous attempt by this user, if any. Only needed * if $attemptnumber > 1 and $quiz->attemptonlast is true. * @param int $timenow the time the attempt was started at. * @param bool $ispreview whether this new attempt is a preview. @@ -1353,7 +1354,7 @@ function quiz_questions_per_page_options() { /** * Get the human-readable name for a quiz attempt state. - * @param string $state one of the state constants like {@link quiz_attempt::IN_PROGRESS}. + * @param string $state one of the state constants like {@see quiz_attempt::IN_PROGRESS}. * @return string The lang string to describe that state. */ function quiz_attempt_state_name($state) { diff --git a/mod/quiz/processattempt.php b/mod/quiz/processattempt.php index 8e0d594550b..4a871af17ce 100644 --- a/mod/quiz/processattempt.php +++ b/mod/quiz/processattempt.php @@ -28,6 +28,8 @@ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ +use mod_quiz\quiz_attempt; + require_once(__DIR__ . '/../../config.php'); require_once($CFG->dirroot . '/mod/quiz/locallib.php'); diff --git a/mod/quiz/report/grading/report.php b/mod/quiz/report/grading/report.php index c43532be94d..9192a8179e5 100644 --- a/mod/quiz/report/grading/report.php +++ b/mod/quiz/report/grading/report.php @@ -15,6 +15,7 @@ // along with Moodle. If not, see . use mod_quiz\local\reports\report_base; +use mod_quiz\quiz_attempt; defined('MOODLE_INTERNAL') || die(); diff --git a/mod/quiz/report/overview/overview_table.php b/mod/quiz/report/overview/overview_table.php index 681ee0f4ce4..f1ab1da042b 100644 --- a/mod/quiz/report/overview/overview_table.php +++ b/mod/quiz/report/overview/overview_table.php @@ -15,6 +15,7 @@ // along with Moodle. If not, see . use mod_quiz\local\reports\attempts_report_table; +use mod_quiz\quiz_attempt; /** * This is a table subclass for displaying the quiz grades report. diff --git a/mod/quiz/report/overview/report.php b/mod/quiz/report/overview/report.php index 4469aa77c4b..83c41a4dda6 100644 --- a/mod/quiz/report/overview/report.php +++ b/mod/quiz/report/overview/report.php @@ -24,6 +24,7 @@ use mod_quiz\local\reports\attempts_report; use mod_quiz\question\bank\qbank_helper; +use mod_quiz\quiz_attempt; defined('MOODLE_INTERNAL') || die(); diff --git a/mod/quiz/report/overview/tests/report_test.php b/mod/quiz/report/overview/tests/report_test.php index 309a78d430a..6d4fc89384c 100644 --- a/mod/quiz/report/overview/tests/report_test.php +++ b/mod/quiz/report/overview/tests/report_test.php @@ -18,9 +18,9 @@ namespace quiz_overview; use core_question\local\bank\question_version_status; use mod_quiz\external\submit_question_version; +use mod_quiz\quiz_attempt; use question_engine; use quiz; -use quiz_attempt; use mod_quiz\local\reports\attempts_report; use quiz_overview_options; use quiz_overview_report; diff --git a/mod/quiz/report/responses/first_or_all_responses_table.php b/mod/quiz/report/responses/first_or_all_responses_table.php index 0f96e7b0826..b9787cf8af3 100644 --- a/mod/quiz/report/responses/first_or_all_responses_table.php +++ b/mod/quiz/report/responses/first_or_all_responses_table.php @@ -23,6 +23,8 @@ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ +use mod_quiz\quiz_attempt; + defined('MOODLE_INTERNAL') || die(); /** diff --git a/mod/quiz/report/responses/last_responses_table.php b/mod/quiz/report/responses/last_responses_table.php index 92eb5ba828e..033de2a9311 100644 --- a/mod/quiz/report/responses/last_responses_table.php +++ b/mod/quiz/report/responses/last_responses_table.php @@ -15,7 +15,7 @@ // along with Moodle. If not, see . use mod_quiz\local\reports\attempts_report_table; - +use mod_quiz\quiz_attempt; /** * This is a table subclass for displaying the quiz responses report. diff --git a/mod/quiz/report/responses/tests/responses_from_steps_walkthrough_test.php b/mod/quiz/report/responses/tests/responses_from_steps_walkthrough_test.php index cb16d9ad759..909c706241c 100644 --- a/mod/quiz/report/responses/tests/responses_from_steps_walkthrough_test.php +++ b/mod/quiz/report/responses/tests/responses_from_steps_walkthrough_test.php @@ -16,8 +16,8 @@ namespace quiz_responses; +use mod_quiz\quiz_attempt; use question_bank; -use quiz_attempt; defined('MOODLE_INTERNAL') || die(); diff --git a/mod/quiz/report/statistics/classes/task/recalculate.php b/mod/quiz/report/statistics/classes/task/recalculate.php index 6d5906be865..425925f0eba 100644 --- a/mod/quiz/report/statistics/classes/task/recalculate.php +++ b/mod/quiz/report/statistics/classes/task/recalculate.php @@ -16,7 +16,7 @@ namespace quiz_statistics\task; -use quiz_attempt; +use mod_quiz\quiz_attempt; use quiz; use quiz_statistics_report; diff --git a/mod/quiz/report/statistics/statisticslib.php b/mod/quiz/report/statistics/statisticslib.php index 832e14be64b..383044ab643 100644 --- a/mod/quiz/report/statistics/statisticslib.php +++ b/mod/quiz/report/statistics/statisticslib.php @@ -23,6 +23,8 @@ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ +use mod_quiz\quiz_attempt; + defined('MOODLE_INTERNAL') || die; /** diff --git a/mod/quiz/review.php b/mod/quiz/review.php index ddb9d25fe00..0c907c1f421 100644 --- a/mod/quiz/review.php +++ b/mod/quiz/review.php @@ -27,6 +27,7 @@ use mod_quiz\output\navigation_panel_review; use mod_quiz\output\renderer; +use mod_quiz\quiz_attempt; require_once(__DIR__ . '/../../config.php'); require_once($CFG->dirroot . '/mod/quiz/locallib.php'); diff --git a/mod/quiz/startattempt.php b/mod/quiz/startattempt.php index b71771ab0c8..f1dea6bce10 100644 --- a/mod/quiz/startattempt.php +++ b/mod/quiz/startattempt.php @@ -26,6 +26,8 @@ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ +use mod_quiz\quiz_attempt; + require_once(__DIR__ . '/../../config.php'); require_once($CFG->dirroot . '/mod/quiz/locallib.php'); diff --git a/mod/quiz/tests/attempt_test.php b/mod/quiz/tests/attempt_test.php index 0b78eae2cba..8faefc11d4a 100644 --- a/mod/quiz/tests/attempt_test.php +++ b/mod/quiz/tests/attempt_test.php @@ -18,7 +18,6 @@ namespace mod_quiz; use question_engine; use quiz; -use quiz_attempt; defined('MOODLE_INTERNAL') || die(); diff --git a/mod/quiz/tests/attempt_walkthrough_from_csv_test.php b/mod/quiz/tests/attempt_walkthrough_from_csv_test.php index 788b3f577f9..4798e9036da 100644 --- a/mod/quiz/tests/attempt_walkthrough_from_csv_test.php +++ b/mod/quiz/tests/attempt_walkthrough_from_csv_test.php @@ -18,7 +18,6 @@ namespace mod_quiz; use question_engine; use quiz; -use quiz_attempt; defined('MOODLE_INTERNAL') || die(); diff --git a/mod/quiz/tests/attempt_walkthrough_test.php b/mod/quiz/tests/attempt_walkthrough_test.php index cbeb26db77b..8d3a80d753a 100644 --- a/mod/quiz/tests/attempt_walkthrough_test.php +++ b/mod/quiz/tests/attempt_walkthrough_test.php @@ -19,7 +19,6 @@ namespace mod_quiz; use question_bank; use question_engine; use quiz; -use quiz_attempt; defined('MOODLE_INTERNAL') || die(); diff --git a/mod/quiz/tests/behat/behat_mod_quiz.php b/mod/quiz/tests/behat/behat_mod_quiz.php index cc0b35eea7f..3f573c32712 100644 --- a/mod/quiz/tests/behat/behat_mod_quiz.php +++ b/mod/quiz/tests/behat/behat_mod_quiz.php @@ -31,6 +31,7 @@ require_once(__DIR__ . '/../../../../question/tests/behat/behat_question_base.ph use Behat\Gherkin\Node\TableNode as TableNode; use Behat\Mink\Exception\ExpectationException as ExpectationException; +use mod_quiz\quiz_attempt; /** * Steps definitions related to mod_quiz. diff --git a/mod/quiz/tests/custom_completion_test.php b/mod/quiz/tests/custom_completion_test.php index 0420ffad606..7ba00e1ddaa 100644 --- a/mod/quiz/tests/custom_completion_test.php +++ b/mod/quiz/tests/custom_completion_test.php @@ -25,7 +25,6 @@ use grade_item; use mod_quiz\completion\custom_completion; use question_engine; use quiz; -use quiz_attempt; defined('MOODLE_INTERNAL') || die(); diff --git a/mod/quiz/tests/event/events_test.php b/mod/quiz/tests/event/events_test.php index 5229e87b96b..78f6fe40e6c 100644 --- a/mod/quiz/tests/event/events_test.php +++ b/mod/quiz/tests/event/events_test.php @@ -25,8 +25,8 @@ namespace mod_quiz\event; +use mod_quiz\quiz_attempt; use quiz; -use quiz_attempt; use context_module; defined('MOODLE_INTERNAL') || die(); diff --git a/mod/quiz/tests/external/external_test.php b/mod/quiz/tests/external/external_test.php index 58b085df251..66f32c13e3b 100644 --- a/mod/quiz/tests/external/external_test.php +++ b/mod/quiz/tests/external/external_test.php @@ -27,11 +27,11 @@ namespace mod_quiz\external; use externallib_advanced_testcase; +use mod_quiz\quiz_attempt; use mod_quiz_external; use mod_quiz\question\display_options; use moodle_exception; use quiz; -use quiz_attempt; defined('MOODLE_INTERNAL') || die(); diff --git a/mod/quiz/tests/generator/lib.php b/mod/quiz/tests/generator/lib.php index cccdaf2f9df..9e728ce1f38 100644 --- a/mod/quiz/tests/generator/lib.php +++ b/mod/quiz/tests/generator/lib.php @@ -14,6 +14,8 @@ // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . +use mod_quiz\quiz_attempt; + defined('MOODLE_INTERNAL') || die(); /** diff --git a/mod/quiz/tests/lib_test.php b/mod/quiz/tests/lib_test.php index a9ed6e1d0b1..5515b4a338e 100644 --- a/mod/quiz/tests/lib_test.php +++ b/mod/quiz/tests/lib_test.php @@ -24,10 +24,7 @@ */ namespace mod_quiz; -use mod_quiz\external\submit_question_version; -use mod_quiz\question\bank\qbank_helper; use quiz; -use quiz_attempt; defined('MOODLE_INTERNAL') || die(); diff --git a/mod/quiz/tests/locallib_test.php b/mod/quiz/tests/locallib_test.php index 79a93f7a5f4..b73248fc984 100644 --- a/mod/quiz/tests/locallib_test.php +++ b/mod/quiz/tests/locallib_test.php @@ -25,7 +25,6 @@ namespace mod_quiz; use mod_quiz\output\renderer; -use quiz_attempt; use mod_quiz\question\display_options; defined('MOODLE_INTERNAL') || die(); diff --git a/mod/quiz/tests/privacy/provider_test.php b/mod/quiz/tests/privacy/provider_test.php index a7292e7170e..88fa323d2f3 100644 --- a/mod/quiz/tests/privacy/provider_test.php +++ b/mod/quiz/tests/privacy/provider_test.php @@ -28,6 +28,7 @@ use core_privacy\local\request\deletion_criteria; use core_privacy\local\request\writer; use mod_quiz\privacy\provider; use mod_quiz\privacy\helper; +use mod_quiz\quiz_attempt; defined('MOODLE_INTERNAL') || die(); @@ -188,7 +189,7 @@ class provider_test extends \core_privacy\tests\provider_testcase { $attempt = $attemptobj->get_attempt(); $this->assertTrue(isset($attemptdata->state)); - $this->assertEquals(\quiz_attempt::state_name($attemptobj->get_state()), $attemptdata->state); + $this->assertEquals(quiz_attempt::state_name($attemptobj->get_state()), $attemptdata->state); $this->assertTrue(isset($attemptdata->timestart)); $this->assertTrue(isset($attemptdata->timefinish)); $this->assertTrue(isset($attemptdata->timemodified)); @@ -212,7 +213,7 @@ class provider_test extends \core_privacy\tests\provider_testcase { $this->setUser(); provider::delete_data_for_user($approvedcontextlist); $this->expectException(\dml_missing_record_exception::class); - \quiz_attempt::create($attemptobj->get_quizid()); + quiz_attempt::create($attemptobj->get_quizid()); } /** @@ -258,7 +259,7 @@ class provider_test extends \core_privacy\tests\provider_testcase { quiz_attempt_save_started($quizobj, $quba, $attempt); // Answer the questions. - $attemptobj = \quiz_attempt::create($attempt->id); + $attemptobj = quiz_attempt::create($attempt->id); $tosubmit = [ 1 => ['answer' => 'frog'], @@ -268,7 +269,7 @@ class provider_test extends \core_privacy\tests\provider_testcase { $attemptobj->process_submitted_actions($starttime, false, $tosubmit); // Finish the attempt. - $attemptobj = \quiz_attempt::create($attempt->id); + $attemptobj = quiz_attempt::create($attempt->id); $this->assertTrue($attemptobj->has_response_to_at_least_one_graded_question()); $attemptobj->process_finish($starttime, false); @@ -392,7 +393,7 @@ class provider_test extends \core_privacy\tests\provider_testcase { * Create a test quiz for the specified course. * * @param \stdClass $course - * @return array + * @return \stdClass */ protected function create_test_quiz($course) { global $DB; @@ -441,7 +442,7 @@ class provider_test extends \core_privacy\tests\provider_testcase { quiz_attempt_save_started($quizobj, $quba, $attempt); // Answer the questions. - $attemptobj = \quiz_attempt::create($attempt->id); + $attemptobj = quiz_attempt::create($attempt->id); $tosubmit = [ 1 => ['answer' => 'frog'], @@ -451,7 +452,7 @@ class provider_test extends \core_privacy\tests\provider_testcase { $attemptobj->process_submitted_actions($starttime, false, $tosubmit); // Finish the attempt. - $attemptobj = \quiz_attempt::create($attempt->id); + $attemptobj = quiz_attempt::create($attempt->id); $attemptobj->process_finish($starttime, false); $this->setUser(); diff --git a/mod/quiz/tests/quiz_notify_attempt_manual_grading_completed_test.php b/mod/quiz/tests/quiz_notify_attempt_manual_grading_completed_test.php index 44c189dfb9e..11968dfd905 100644 --- a/mod/quiz/tests/quiz_notify_attempt_manual_grading_completed_test.php +++ b/mod/quiz/tests/quiz_notify_attempt_manual_grading_completed_test.php @@ -30,7 +30,6 @@ use context_module; use mod_quiz\task\quiz_notify_attempt_manual_grading_completed; use question_engine; use quiz; -use quiz_attempt; use stdClass; defined('MOODLE_INTERNAL') || die(); diff --git a/mod/quiz/tests/quiz_question_helper_test_trait.php b/mod/quiz/tests/quiz_question_helper_test_trait.php index 1d315d1d25c..b41722a57ec 100644 --- a/mod/quiz/tests/quiz_question_helper_test_trait.php +++ b/mod/quiz/tests/quiz_question_helper_test_trait.php @@ -13,6 +13,7 @@ // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . +use mod_quiz\quiz_attempt; /** * Helper trait for quiz question unit tests. diff --git a/mod/quiz/tests/reportlib_test.php b/mod/quiz/tests/reportlib_test.php index 2c177f7def1..bcd41c1ae50 100644 --- a/mod/quiz/tests/reportlib_test.php +++ b/mod/quiz/tests/reportlib_test.php @@ -16,8 +16,6 @@ namespace mod_quiz; -use quiz_attempt; - defined('MOODLE_INTERNAL') || die(); global $CFG; diff --git a/mod/quiz/upgrade.txt b/mod/quiz/upgrade.txt index 3d868bb67b4..68671836b1f 100644 --- a/mod/quiz/upgrade.txt +++ b/mod/quiz/upgrade.txt @@ -48,6 +48,7 @@ This files describes API changes in the quiz code. - quiz_nav_panel_base => mod_quiz\output\navigation_panel_base - quiz_attempt_nav_panel => mod_quiz\output\navigation_panel_attempt - quiz_review_nav_panel => mod_quiz\output\navigation_panel_review + - quiz_attempt => mod_quiz\quiz_attempt * The following classes have been deprecated: - mod_quiz_overdue_attempt_updater - merged into mod_quiz\task\update_overdue_attempts diff --git a/mod/quiz/view.php b/mod/quiz/view.php index 67a27968e2d..eb1ac271364 100644 --- a/mod/quiz/view.php +++ b/mod/quiz/view.php @@ -26,6 +26,7 @@ use mod_quiz\access_manager; use mod_quiz\output\renderer; use mod_quiz\output\view_page; +use mod_quiz\quiz_attempt; require_once(__DIR__ . '/../../config.php'); require_once($CFG->libdir.'/gradelib.php'); diff --git a/question/bank/statistics/tests/helper_test.php b/question/bank/statistics/tests/helper_test.php index f80084e91d9..777cb79a4df 100644 --- a/question/bank/statistics/tests/helper_test.php +++ b/question/bank/statistics/tests/helper_test.php @@ -17,9 +17,9 @@ namespace qbank_statistics; use core_question\statistics\questions\all_calculated_for_qubaid_condition; +use mod_quiz\quiz_attempt; use quiz; use question_engine; -use quiz_attempt; /** * Tests for question statistics. diff --git a/question/bank/usage/tests/helper_test.php b/question/bank/usage/tests/helper_test.php index f337fc31caa..3f50a0a4966 100644 --- a/question/bank/usage/tests/helper_test.php +++ b/question/bank/usage/tests/helper_test.php @@ -16,6 +16,8 @@ namespace qbank_usage; +use mod_quiz\quiz_attempt; + /** * Helper test. * @@ -75,7 +77,7 @@ class helper_test extends \advanced_testcase { $attempt = quiz_create_attempt($quizobj, 1, false, $timenow, false, $user->id); quiz_start_new_attempt($quizobj, $quba, $attempt, 1, $timenow); quiz_attempt_save_started($quizobj, $quba, $attempt); - \quiz_attempt::create($attempt->id); + quiz_attempt::create($attempt->id); } /** diff --git a/question/bank/usage/tests/question_usage_test.php b/question/bank/usage/tests/question_usage_test.php index 3994c82d224..377246a6cb2 100644 --- a/question/bank/usage/tests/question_usage_test.php +++ b/question/bank/usage/tests/question_usage_test.php @@ -16,6 +16,8 @@ namespace qbank_usage; +use mod_quiz\quiz_attempt; + /** * Tests for the data of question usage from differnet areas like helper or usage table. * @@ -68,7 +70,7 @@ class question_usage_test extends \advanced_testcase { $attempt = quiz_create_attempt($quizobj, 1, false, $timenow, false, $user->id); quiz_start_new_attempt($quizobj, $quba, $attempt, 1, $timenow); quiz_attempt_save_started($quizobj, $quba, $attempt); - $attemptdata = \quiz_attempt::create($attempt->id); + $attemptdata = quiz_attempt::create($attempt->id); $this->setAdminUser(); $PAGE->set_url(new \moodle_url('/'));