gocodebox/lifterlms

View on GitHub
includes/models/model.llms.quiz.attempt.php

Summary

Maintainability
D
1 day
Test Coverage
C
72%
<?php
/**
 * Quiz Attempt Model
 *
 * @package LifterLMS/Models/Classes
 *
 * @since 3.9.0
 * @version 7.4.1
 */

defined( 'ABSPATH' ) || exit;

/**
 * LLMS_Quiz_Attempt model class
 *
 * @since 3.9.0
 * @since 3.9.2 Added `calculate_point_weight()`, `get_question_order()`, `is_passing()` methods.
 * @since 3.16.0 Unknown.
 * @since 3.16.7 Unknown.
 * @since 3.17.1 Unknown.
 * @since 3.19.2 Unknown.
 * @since 3.24.0 Unknown.
 * @since 3.26.3 Unknown.
 * @since 3.29.0 Unknown.
 * @since 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.
 * @since 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()`.
 * @since 4.3.0 Added `$type` property declaration.
 */
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;
    }

    /**
     * Delete the object from the database
     *
     * Overrides the parent method to perform other actions before deletion.
     *
     * @since 4.2.0
     *
     * @return bool `true` on success, `false` otherwise.
     */
    public function delete() {

        if ( ! $this->id ) {
            return false;
        }

        $lesson = llms_get_post( $this->get( 'lesson_id' ) );

        // No lesson, or lesson incomplete, nothing special to do here.
        if ( ! $lesson || ! ( $lesson instanceof LLMS_Lesson ) || ! llms_is_complete( $this->get( 'student_id' ), $this->get( 'lesson_id' ), 'lesson' ) ) {
            return parent::delete();
        }

        /**
         * Prepare the query args to retrieve at least another sibling attempt,
         * excluding the current one.
         */
        $sibling_query_args = array(
            'exclude'  => $this->get_id( 'id' ),
            'per_page' => 1,
        );

        /**
         * If this lesson requires a passing grade, then retrieve only the possible passed sibling
         * that might have been triggered the lesson completion.
         */
        if ( llms_parse_bool( $lesson->get( 'require_passing_grade' ) ) ) {
            $sibling_query_args['status'] = array(
                'pass',
            );
        }

        $sibling_attempts = $this->get_siblings( $sibling_query_args, 'ids' );

        // If this is the only one relevant left attempt.
        if ( empty( $sibling_attempts ) ) {
            llms_mark_incomplete(
                $this->get( 'student_id' ),
                $this->get( 'lesson_id' ),
                'lesson',
                'quiz_' . $this->get( 'quiz_id' )
            );
        }

        return parent::delete();
    }

}