LLMS_Quiz_Attempt
LLMS_Quiz_Attempt model class
Source Source
File: includes/models/model.llms.quiz.attempt.php
class LLMS_Quiz_Attempt extends LLMS_Abstract_Database_Store { /** * Array of table column name => format * * @var array */ protected $columns = array( 'student_id' => '%d', 'quiz_id' => '%d', 'lesson_id' => '%d', 'start_date' => '%s', 'update_date' => '%s', 'end_date' => '%s', 'status' => '%s', 'attempt' => '%d', 'grade' => '%f', 'questions' => '%s', ); protected $date_created = 'start_date'; protected $date_updated = 'update_date'; /** * Database Table Name * * @var string */ protected $table = 'quiz_attempts'; /** * The record type * * @var string */ protected $type = 'quiz_attempt'; /** * Constructor * * @since 3.9.0 * @since 3.16.0 Unknown. * * @param mixed $item Optional. Array/obj of attempt data or int. Default `null`. * @return void */ public function __construct( $item = null ) { if ( is_numeric( $item ) ) { $this->id = $item; } elseif ( is_object( $item ) && isset( $item->id ) ) { $this->id = $item->id; } elseif ( is_array( $item ) && isset( $item['id'] ) ) { $this->id = $item['id']; } if ( ! $this->id ) { if ( is_array( $item ) || is_object( $item ) ) { $this->setup( $item ); } parent::__construct(); } } /** * Answer a question * * Records the selected option and whether or not the selected option was the correct option. * * Automatically updates & saves the attempt to the database * * @since 3.9.0 * @since 3.16.0 Updated to accommodate quiz builder improvements. * @since 4.0.0 Explicitly set earned points to `0` when answering incorrectly. * Exit the loop as soon as we find our question. * Use strict comparison for IDs. * * @param int $question_id WP_Post ID of the LLMS_Question. * @param string[] $answer Array of selected choice IDs (for core question types) or an array containing the user-submitted answer(s). * @return LLMS_Quiz_Attempt Instance of the current attempt. */ public function answer_question( $question_id, $answer ) { $questions = $this->get_questions(); foreach ( $questions as $key => $data ) { if ( absint( $question_id ) !== absint( $data['id'] ) ) { continue; } $question = llms_get_post( $question_id ); $graded = $question->grade( $answer ); $questions[ $key ]['answer'] = $answer; $questions[ $key ]['correct'] = $graded; $questions[ $key ]['earned'] = llms_parse_bool( $graded ) ? $questions[ $key ]['points'] : 0; break; } $this->set_questions( $questions )->save(); return $this; } /** * Calculate and the grade for a completed quiz * * @since 3.9.0 * @since 3.24.0 Unknown. * @since 4.0.0 Remove reliance on deprecated method `LLMS_Quiz::get_passing_percent()`. * * @return LLMS_Quiz_Attempt Instance of the current quiz attempt. */ public function calculate_grade() { $status = 'pending'; if ( $this->is_auto_gradeable() ) { $grade = llms()->grades()->round( $this->get_count( 'earned' ) * $this->calculate_point_weight() ); $quiz = $this->get_quiz(); $min_grade = $quiz ? $quiz->get( 'passing_percent' ) : 100; $this->set( 'grade', $grade ); $status = ( $min_grade <= $grade ) ? 'pass' : 'fail'; } $this->set_status( $status ); return $this; } /** * Calculate the weight of each point * * @since 3.9.2 * @since 3.16.0 Unknown. * * @return float */ private function calculate_point_weight() { $available = $this->get_count( 'available_points' ); return ( $available > 0 ) ? ( 100 / $available ) : 0; } /** * Run actions designating quiz completion * * @since 3.16.0 * @since 3.17.1 Unknown. * * @return void */ public function do_completion_actions() { // Do quiz completion actions. do_action( 'lifterlms_quiz_completed', $this->get_student()->get_id(), $this->get( 'quiz_id' ), $this ); $passed = false; switch ( $this->get( 'status' ) ) { case 'pass': $passed = true; do_action( 'lifterlms_quiz_passed', $this->get_student()->get_id(), $this->get( 'quiz_id' ), $this ); break; case 'fail': do_action( 'lifterlms_quiz_failed', $this->get_student()->get_id(), $this->get( 'quiz_id' ), $this ); break; case 'pending': do_action( 'lifterlms_quiz_pending', $this->get_student()->get_id(), $this->get( 'quiz_id' ), $this ); break; } } /** * End a quiz attempt * * Sets end date, unsets the quiz as the current quiz, and records a grade. * * @since 3.9.0 * @since 3.16.0 Unknown. * * @param boolean $silent Optional. If `true`, will not trigger actions or mark related lesson as complete. Default `false`. * @return LLMS_Quiz_Attempt This quiz attempt instance (for chaining). */ public function end( $silent = false ) { $this->set( 'end_date', current_time( 'mysql' ) ); $this->calculate_grade()->save(); if ( ! $silent ) { $this->do_completion_actions(); } // Clear "cached" grade so it's recalculated next time it's requested. $this->get_student()->set( 'overall_grade', '' ); return $this; } /** * Get sibling attempts * * @since 4.2.0 * * @param array $args Optional. List of args to be passed as params of the quiz attempts query. Default empty array. * See `LLMS_Query_Quiz_Attempt` and `LLMS_Database_Query` for the list of args. * By default the `per_page` param is set to 1000. * @param string $return Optional. Type of return [ids|attempts]. Default 'attempts'. * @return int[]|LLMS_Quiz_Attempt[] Type depends on value of `$return`. */ public function get_siblings( $args = array(), $return = 'attempts' ) { $defaults = array( 'per_page' => 1000, ); $args = wp_parse_args( $args, $defaults ); $query = new LLMS_Query_Quiz_Attempt( array_merge( $args, array( 'student_id' => $this->get( 'student_id' ), 'quiz_id' => $this->get( 'quiz_id' ), ) ) ); return 'ids' === $return ? wp_list_pluck( $query->get_results(), 'id' ) : $query->get_attempts(); } /** * Retrieve a count for various pieces of information related to the attempt * * @since 3.9.0 * @since 3.19.2 Unknown. * @since 4.2.0 Ensure only one return point. * * @param string $key The key of the data to count. * @return int */ public function get_count( $key ) { $count = 0; $questions = $this->get_questions(); switch ( $key ) { case 'available_points': case 'correct_answers': case 'earned': case 'gradeable_questions': // Like "questions" but excludes content questions. case 'points': // Legacy version of earned. foreach ( $questions as $data ) { // Get the total number of correct answers. if ( 'correct_answers' === $key ) { if ( 'yes' === $data['correct'] ) { $count++; } } elseif ( 'earned' === $key || 'points' === $key ) { $count += $data['earned']; // Get the total number of possible points. } elseif ( 'available_points' === $key ) { $count += $data['points']; } elseif ( 'gradeable_questions' === $key ) { if ( $data['points'] ) { $count++; } } } break; case 'questions': $count = count( $questions ); break; } return $count; } /** * Retrieve a formatted date * * @since 3.9.0 * @since 3.16.0 Unknown. * * @param string $key 'start' or 'end'. * @param string $format Optional. Output date format (PHP), uses WordPress format options if none provided. * If not provided defaults to WP date format options. * @return string */ public function get_date( $key, $format = null ) { $date = strtotime( $this->get( $key . '_date' ) ); $format = ! $format ? get_option( 'date_format' ) . ' ' . get_option( 'time_format' ) : $format; return date_i18n( $format, $date ); } /** * Retrieve the first question for the attempt * * @since 3.9.0 * @since 3.16.0 Unknown. * * @return int|false */ public function get_first_question() { $questions = $this->get_questions(); if ( $questions ) { $first = array_shift( $questions ); return $first['id']; } return false; } /** * Get the numeric order of a question in a given quiz * * @since 3.9.2 * @since 3.16.0 Unknown. * @since 4.2.0 Use strict type comparison. * * @param int $question_id WP Post ID of the LLMS_Question. * @return int */ public function get_question_order( $question_id ) { foreach ( $this->get_questions() as $order => $data ) { if ( absint( $data['id'] ) === $question_id ) { return $order + 1; } } return 0; } /** * Get an encoded attempt key that can be passed in URLs and the like * * @since 3.9.0 * @since 3.16.7 Unknown. * * @return string */ public function get_key() { return LLMS_Hasher::hash( $this->get( 'id' ) ); } /** * Retrieve an array of blank questions for insertion into a new attempt during initialization. * * @since 3.9.0 * @since 3.16.0 Unknown. * @since 7.4.1 Moved randomization into `LLMS_Quiz_Attempt::randomize_attempt_questions()`. * * @return array */ private function get_new_questions() { $quiz = llms_get_post( $this->get( 'quiz_id' ) ); $questions = array(); if ( $quiz ) { /** * Filter randomize value for quiz questions. * * @since 7.4.0 * * @param bool $randomize The randomize boolean value. * @param LLMS_Quiz $quiz LLMS_Quiz instance. * @param LLMS_Quiz_Attempt $attempt LLMS_Quiz_Attempt instance. */ $randomize = apply_filters( 'llms_quiz_attempt_questions_randomize', llms_parse_bool( $quiz->get( 'random_questions' ) ), $quiz, $this ); /** * Filter questions for the quiz. * * Sets the questions to be used for the quiz. * * @since 7.4.0 * * @param array $questions Array of LLMS_Question objects. * @param LLMS_Quiz $quiz LLMS_Quiz instance. * @param LLMS_Quiz_Attempt $attempt LLMS_Quiz_Attempt instance. */ $quiz_questions = apply_filters( 'llms_quiz_attempt_questions', $quiz->get_questions(), $quiz, $this ); foreach ( $quiz_questions as $index => $question ) { $questions[] = array( 'id' => $question->get( 'id' ), 'earned' => 0, 'points' => $question->supports( 'points' ) ? $question->get( 'points' ) : 0, 'answer' => null, 'correct' => null, ); } /** * Filter attempt's questions array for the quiz. * * @since 7.4.1 * * @param array $questions Array of question (each question is an array itself). * @param LLMS_Quiz $quiz LLMS_Quiz instance. * @param LLMS_Quiz_Attempt $attempt LLMS_Quiz_Attempt instance. */ $questions = apply_filters( 'llms_quiz_attempt_questions_array', $questions, $quiz, $this ); if ( $randomize ) { $questions = self::randomize_attempt_questions( $questions ); } } return $questions; } /** * Retrieve the next unanswered question in the attempt * * @since 3.9.0 * @since 3.16.0 Unknown. * @since 4.2.0 Use strict type comparison. * * @param int $last_question Optional. WP Post ID of the current LLMS_Question the "next" refers to. Default `null`. * @return int|false */ public function get_next_question( $last_question = null ) { $next = false; foreach ( $this->get_questions() as $question ) { if ( $next || is_null( $question['answer'] ) ) { return $question['id']; // When rewinding and moving back through we don't want to skip questions. } elseif ( $last_question && absint( $last_question ) === absint( $question['id'] ) ) { $next = true; } } return false; } /** * Retrieve a permalink for the attempt * * @since 3.9.0 * * @return string */ public function get_permalink() { if ( ! $this->get_quiz() ) { return ''; } return add_query_arg( 'attempt_key', $this->get_key(), get_permalink( $this->get_quiz()->get( 'id' ) ) ); } /** * Get array of serialized questions * * @since 3.16.0 * * @param boolean $cache Optional. If `true`, save data to to the object for future gets. Default `true`. * @return array */ public function get_questions( $cache = true ) { $questions = $this->get( 'questions', $cache ); if ( $questions ) { return unserialize( $questions ); } return array(); } /** * Retrieve an array of attempt question objects * * @since 3.16.0 * @since 5.3.0 Add a parameter to filter out removed questions. * * @param boolean $cache Optional. If `true`, save data to to the object for future gets. Default `true`. * Cached questions won't take into account the `$filte_removed` parameter. * @param boolean $filter_removed Optional. If `true`, removed questions will be filtered out. Default `false`. * @return array */ public function get_question_objects( $cache = true, $filter_removed = false ) { $questions = array(); foreach ( $this->get_questions( $cache ) as $qdata ) { $question = new LLMS_Quiz_Attempt_Question( $qdata ); if ( ! $filter_removed || $question->get_question() instanceof LLMS_Question ) { $questions[] = $question; } } return $questions; } /** * Get an instance of the LLMS_Quiz for the attempt * * @since 3.9.0 * * @return LLMS_Quiz */ public function get_quiz() { return llms_get_post( $this->get( 'quiz_id' ) ); } /** * Get an LLMS_Student for the quiz * * @since 3.9.0 * * @return LLMS_Student */ public function get_student() { return llms_get_student( $this->get( 'student_id' ) ); } /** * Get the time spent on the quiz from start to end * * @since 3.9.0 * * @param integer $precision Precision passed to `llms_get_date_diff()`. * @return string */ public function get_time( $precision = 2 ) { return llms_get_date_diff( $this->get_date( 'start', 'U' ), $this->get_date( 'end', 'U' ), $precision ); } /** * Retrieve a title-like string * * @since 3.16.0 * @since 3.26.3 Unknown. * * @return string */ public function get_title() { $student = $this->get_student(); $name = $student ? $this->get_student()->get_name() : apply_filters( 'llms_quiz_attempt_deleted_student_name', __( '[Deleted]', 'lifterlms' ) ); return sprintf( __( 'Quiz Attempt #%1$d by %2$s', 'lifterlms' ), $this->get( 'attempt' ), $name ); } /** * Initialize a new quiz attempt by quiz and lesson for a user * * If no user found throws an Exception. * * @since 3.9.0 * @version 3.16.0 * * @throws Exception When the user is not logged in. * * @param int $quiz_id WP Post ID of the quiz. * @param int $lesson_id WP Post ID of the lesson. * @param mixed $student Optional. Accepts anything that can be passed to llms_get_student. * If no user is passed the current user will be used. Default `null`. * * @return obj */ public static function init( $quiz_id, $lesson_id, $student = null ) { $student = llms_get_student( $student ); if ( ! $student ) { throw new Exception( __( 'You must be logged in to take a quiz!', 'lifterlms' ) ); } // Initialize a new attempt. $attempt = new self(); $attempt->set( 'quiz_id', $quiz_id ); $attempt->set( 'lesson_id', $lesson_id ); $attempt->set( 'student_id', $student->get_id() ); $attempt->set_status( 'incomplete' ); $attempt->set_questions( $attempt->get_new_questions() ); $number = 1; $last_attempt = $student->quizzes()->get_last_attempt( $quiz_id ); if ( $last_attempt ) { $number = absint( $last_attempt->get( 'attempt' ) ) + 1; } $attempt->set( 'attempt', $number ); return $attempt; } /** * Randomize attempt questions. * * Logic moved from `LLMS_Quiz_Attempt::get_new_questions()`. * * @since 7.4.1 * * @param array $questions Array of attempt's questions (each question is an array itself). * @return array. */ public static function randomize_attempt_questions( $questions ) { if ( empty( $questions ) ) { return $questions; } // Array of indexes that will be locked during shuffling. $locks = array(); foreach ( $questions as $index => $question_array ) { $question = llms_get_post( $question_array['id'] ); // If randomization is enabled, store the questions index so we can lock it during randomization. if ( $question->supports( 'random_lock' ) ) { $locks[] = $index; } } // Lifted from https://stackoverflow.com/a/28491007/400568. // I generally comprehend this code but also in a truer way i have no idea... $inc = array(); $i = 0; $j = 0; $l = count( $questions ); $le = count( $locks ); while ( $i < $l ) { if ( $j >= $le || $i < $locks[ $j ] ) { $inc[] = $i; } else { $j++; } $i++; } // Fisher-yates-knuth shuffle variation O(n). $num = count( $inc ); while ( $num-- ) { $perm = wp_rand( 0, $num ); $swap = $questions[ $inc[ $num ] ]; $questions[ $inc[ $num ] ] = $questions[ $inc[ $perm ] ]; $questions[ $inc[ $perm ] ] = $swap; } return $questions; } /** * Determine if the attempt can be autograded * * @since 3.16.0 * * @return bool */ private function is_auto_gradeable() { foreach ( $this->get_question_objects() as $question ) { if ( 'waiting' === $question->get_status() ) { return false; } } return true; } /** * Determine if the attempt was passing * * @since 3.9.2 * @since 3.16.0 Unknown. * * @return boolean */ public function is_passing() { return ( 'pass' === $this->get( 'status' ) ); } /** * Translate attempt related strings * * @since 3.9.0 * @since 3.16.0 Unknown. * @since 4.2.0 Made sure the status key exists to avoid trying to access to array's undefined index. * * @param string $key Key to translate. * @return string */ public function l10n( $key ) { $tkey = ''; switch ( $key ) { case 'passed': // Deprecated. case 'status': $statuses = llms_get_quiz_attempt_statuses(); $status = $this->get( 'status' ); $tkey = ( $status && isset( $statuses[ $status ] ) ) ? $statuses[ $status ] : $tkey; break; } return $tkey; } /** * Setter for serialized questions array * * @since 3.16.0 * * @param array $questions Question data. * @param boolean $save Optional. If `true`, immediately persists to database. Default `false`. * @return LLMS_Quiz_Attempt This quiz attempt instance (for chaining). */ public function set_questions( $questions = array(), $save = false ) { return $this->set( 'questions', serialize( $questions ), $save ); } /** * Set the status of the attempt * * @since 3.16.0 * @since 4.0.0 Use strict comparisons. * * @param string $status Status value. * @param boolean $save If `true`, immediately persists to database. * @return false|LLMS_Quiz_Attempt */ public function set_status( $status, $save = false ) { $statuses = array_keys( llms_get_quiz_attempt_statuses() ); if ( ! in_array( $status, $statuses, true ) ) { return false; } return $this->set( 'status', $status ); } /** * Record the attempt as started * * @since 3.9.0 * * @return LLMS_Quiz_Attempt Instance of the current quiz attempt object. */ public function start() { $this->set( 'start_date', current_time( 'mysql' ) ); $this->save(); return $this; } /** * Retrieve the private data array * * @since 3.9.0 * * @return array */ public function to_array() { return $this->data; }
Expand full source code Collapse full source code View on GitHub
Methods Methods
- __construct — Constructor
- answer_question — Answer a question
- calculate_grade — Calculate and the grade for a completed quiz
- calculate_point_weight — Calculate the weight of each point
- delete — Delete the object from the database
- do_completion_actions — Run actions designating quiz completion
- end — End a quiz attempt
- get_count — Retrieve a count for various pieces of information related to the attempt
- get_date — Retrieve a formatted date
- get_first_question — Retrieve the first question for the attempt
- get_key — Get an encoded attempt key that can be passed in URLs and the like
- get_new_questions — Retrieve an array of blank questions for insertion into a new attempt during initialization
- get_next_question — Retrieve the next unanswered question in the attempt
- get_permalink — Retrieve a permalink for the attempt
- get_question_objects — Retrieve an array of attempt question objects
- get_question_order — Get the numeric order of a question in a given quiz
- get_questions — Get array of serialized questions
- get_quiz — Get an instance of the LLMS_Quiz for the attempt
- get_siblings — Get sibling attempts
- get_status — Get the attempts status based on start and end dates — deprecated
- get_student — Get an LLMS_Student for the quiz
- get_time — Get the time spent on the quiz from start to end
- get_title — Retrieve a title-like string
- init — Initialize a new quiz attempt by quiz and lesson for a user
- is_auto_gradeable — Determine if the attempt can be autograded
- is_passing — Determine if the attempt was passing
- l10n — Translate attempt related strings
- set_questions — Setter for serialized questions array
- set_status — Set the status of the attempt
- start — Record the attempt as started
- to_array — Retrieve the private data array
Changelog Changelog
Version | Description |
---|---|
4.3.0 | Added $type property declaration. |
4.2.0 | Use strict type comparisons where possible. In the l10n() method, made sure the status key exists to avoid trying to access to array's undefined index. Added the public method get_siblings() . |
4.0.0 | Remove reliance on deprecated method LLMS_Quiz::get_passing_percent() & remove deprecated class method get_status() . Fix issue encountered when answering a question incorrectly after initially answering it correctly. |
3.9.2 | Added calculate_point_weight() , get_question_order() , is_passing() methods. |
3.9.0 | |
3.29.0 | Unknown. |
3.26.3 | Unknown. |
3.24.0 | Unknown. |
3.19.2 | Unknown. |
3.17.1 | Unknown. |
3.16.7 | Unknown. |
3.16.0 | Introduced. |