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('/'));