gocodebox/lifterlms

View on GitHub
includes/models/model.llms.question.php

Summary

Maintainability
D
1 day
Test Coverage
D
64%
<?php
/**
 * LifterLMS Quiz Question Model
 *
 * @package LifterLMS/Models/Classes
 *
 * @since 1.0.0
 * @version 7.4.1
 */

defined( 'ABSPATH' ) || exit;

/**
 * LLMS Quiz Question Model class
 *
 * @property string $question_type Type of question.
 *
 * @since 1.0.0
 * @since 3.30.1 Fixed choice sorting issues.
 * @since 3.35.0 Escape `LIKE` clause when retrieving choices.
 * @since 3.38.2 When getting the 'not raw' question_type, made sure to always return a valid value.
 * @since 4.0.0 Remove deprecated class methods.
 */
class LLMS_Question extends LLMS_Post_Model {

    /**
     * Database post type name
     *
     * @var string
     */
    protected $db_post_type = 'llms_question';

    /**
     * Modefl post type name
     *
     * @var string
     */
    protected $model_post_type = 'question';

    /**
     * Map of Model properties to property type
     *
     * @var array
     */
    protected $properties = array(
        'content'                => 'html',
        'clarifications'         => 'html',
        'clarifications_enabled' => 'yesno',
        'description_enabled'    => 'yesno',
        'image'                  => 'array',
        'multi_choices'          => 'yesno',
        'parent_id'              => 'absint',
        'points'                 => 'absint',
        'question_type'          => 'string',
        'question'               => 'html',
        'title'                  => 'html',
        'video_enabled'          => 'yesno',
        'video_src'              => 'string',
    );

    /**
     * Create a new question choice
     *
     * @since 3.16.0
     *
     * @param array $data Array of question choice data.
     * @return string|boolean
     */
    public function create_choice( $data ) {

        $data = wp_parse_args(
            $data,
            array(
                'choice'      => '',
                'choice_type' => 'text',
                'correct'     => false,
                'marker'      => $this->get_next_choice_marker(),
                'question_id' => $this->get( 'id' ),
            )
        );

        $choice = new LLMS_Question_Choice( $this->get( 'id' ) );
        if ( $choice->create( $data ) ) {
            return $choice->get( 'id' );
        }

        return false;

    }

    /**
     * Delete a choice by ID
     *
     * @since 3.16.0
     *
     * @param string $id Choice ID.
     * @return boolean
     */
    public function delete_choice( $id ) {

        $choice = $this->get_choice( $id );
        if ( ! $choice ) {
            return false;
        }
        return $choice->delete();

    }

    /**
     * Retrieve the type of automatic grading that can be performed on the question
     *
     * @since 3.16.0
     *
     * @return string|false
     */
    public function get_auto_grade_type() {

        if ( $this->supports( 'choices' ) && $this->supports( 'grading', 'auto' ) ) {
            return 'choices';
        } elseif ( $this->supports( 'grading', 'conditional' ) && llms_parse_bool( $this->get( 'auto_grade' ) ) ) {
            return 'conditional';
        }

        return false;

    }


    /**
     * An array of default arguments to pass to $this->create() when creating a new post
     *
     * @since 3.16.0
     * @since 3.16.12 Unknown.
     *
     * @param array $args Args of data to be passed to wp_insert_post.
     * @return array
     */
    protected function get_creation_args( $args = null ) {

        // Allow nothing to be passed in.
        if ( empty( $args ) ) {
            $args = array();
        }

        // Backwards compat to original 3.0.0 format when just a title was passed in.
        if ( is_string( $args ) ) {
            $args = array(
                'post_title' => $args,
            );
        }

        if ( isset( $args['title'] ) ) {
            $args['post_title'] = $args['title'];
            unset( $args['title'] );
        }
        if ( isset( $args['content'] ) ) {
            $args['post_content'] = $args['content'];
            unset( $args['content'] );
        }

        $meta = isset( $args['meta_input'] ) ? $args['meta_input'] : array();

        $props = array_diff( array_keys( $this->get_properties() ), array_keys( $this->get_post_properties() ) );

        foreach ( $props as $prop ) {

            if ( isset( $args[ $prop ] ) ) {

                $meta[ $this->meta_prefix . $prop ] = $args[ $prop ];
                unset( $args[ $prop ] );

            }
        }

        $args['meta_input'] = wp_parse_args( $meta, $meta );

        $args = wp_parse_args(
            $args,
            array(
                'comment_status' => 'closed',
                'meta_input'     => array(),
                'menu_order'     => 1,
                'ping_status'    => 'closed',
                'post_author'    => get_current_user_id(),
                'post_content'   => '',
                'post_excerpt'   => '',
                'post_status'    => 'publish',
                'post_title'     => '',
                'post_type'      => $this->get( 'db_post_type' ),
            )
        );

        return apply_filters( "llms_{$this->model_post_type}_get_creation_args", $args, $this );

    }


    /**
     * Getter
     *
     * @since 3.38.2
     *
     * @param string  $key The property key.
     * @param boolean $raw Optional. Whether or not we need to get the raw value. Default false.
     * @return mixed
     */
    public function get( $key, $raw = false ) {

        $value = parent::get( $key, $raw );

        // When getting the 'not raw' value, make sure we always return a valid question type.
        if ( ! $raw && ! $value && 'question_type' === $key ) {
            $value = 'choice';
        }

        return $value;

    }

    /**
     * Retrieve a choice by id
     *
     * @since 3.16.0
     * @since 4.4.0 Use strict comparison.
     *
     * @param string $id Choice ID.
     * @return obj|false
     */
    public function get_choice( $id ) {
        $choice = new LLMS_Question_Choice( $this->get( 'id' ), $id );
        if ( $choice->exists() && absint( $this->get( 'id' ) ) === absint( $choice->get_question_id() ) ) {
            return $choice;
        }
        return false;
    }

    /**
     * Retrieve the question's choices
     *
     * @since 3.16.0
     * @since 3.30.1 Improve choice sorting to accommodate numeric markers.
     * @since 3.35.0 Escape `LIKE` clause.
     * @since 4.4.0 Don't allow objects when using `unserialize()`.
     *
     * @param string $return Optional. Determine how to return the choice data.
     *                       'choices' (default) returns an array of LLMS_Question_Choice objects.
     *                       'ids' returns an array of LLMS_Question_Choice ids.
     * @return array
     */
    public function get_choices( $return = 'choices' ) {

        global $wpdb;
        $results = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
            $wpdb->prepare(
                "SELECT meta_key AS id
                  , meta_value AS data
             FROM {$wpdb->postmeta}
             WHERE post_id = %d
               AND meta_key LIKE %s
            ;",
                $this->get( 'id' ),
                '_llms_choice_%'
            )
        );

        usort( $results, array( $this, 'sort_choices' ) );

        if ( 'ids' === $return ) {
            return wp_list_pluck( $results, 'id' );
        }

        $ret = array();
        foreach ( $results as $result ) {
            $ret[] = new LLMS_Question_Choice( $this->get( 'id' ), unserialize( $result->data, array( 'allowed_classes' => false ) ) ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize
        }

        return $ret;

    }

    /**
     * Retrieve the question description (post_content)
     *
     * Add's extra allowed tags to wp_kses_post allowed tags so that async audio shortcodes will work properly
     *
     * @since 3.16.6
     *
     * @return string
     */
    public function get_description() {

        global $allowedposttags;
        $allowedposttags['source'] = array(
            'src'  => true,
            'type' => true,
        );
        $desc                      = $this->get( 'content' );
        unset( $allowedposttags['source'] );

        return apply_filters( 'llms_' . $this->get( 'question_type' ) . '_question_get_description', $desc, $this );

    }

    /**
     * Retrieve the correct values for a conditionally graded question
     *
     * @since 3.16.15
     *
     * @return array
     */
    public function get_conditional_correct_value() {

        $correct = explode( '|', $this->get( 'correct_value' ) );
        $correct = array_map( 'trim', $correct );

        return $correct;

    }

    /**
     * Retrieve correct choices for a given question
     *
     * @since 3.16.0
     *
     * @return array
     */
    public function get_correct_choice() {

        $correct = false;

        if ( $this->supports( 'choices' ) && $this->supports( 'grading', 'auto' ) ) {

            $multi   = ( 'yes' === $this->get( 'multi_choices' ) );
            $correct = array();

            foreach ( $this->get_choices() as $choice ) {

                if ( $choice->is_correct() ) {
                    $correct[] = $choice->get( 'id' );
                    if ( ! $multi ) {
                        break;
                    }
                }
            }

            // Always sort multi choices for easy auto comparison.
            if ( $multi && $this->supports( 'selectable' ) ) {
                sort( $correct );
            }
        }

        return $correct;

    }

    /**
     * Get the question text (title)
     *
     * @since 3.16.0
     *
     * @return string
     */
    public function get_question( $format = 'html' ) {
        return apply_filters( 'llms_' . $this->get( 'question_type' ) . '_question_get_question', $this->get( 'title' ), $format, $this );
    }

    /**
     * Retrieve child questions (for question group)
     *
     * @since 3.16.0
     *
     * @todo Need to prevent access for non-group questions.
     *
     * @return array
     */
    public function get_questions() {
        return $this->questions()->get_questions();
    }

    /**
     * Retrieve URL for an image associated with the question if it's enabled
     *
     * @since 3.16.0
     *
     * @param string|array $size   Registered image size or a numeric array with width/height.
     * @param null         $unused Unused parameter.
     * @return string Source URL or an Eepty string if no image or not supported.
     */
    public function get_image( $size = 'full', $unused = null ) {

        $url = '';

        if ( $this->has_image() ) {
            $img = $this->get( 'image' );
            if ( isset( $img['id'] ) && is_numeric( $img['id'] ) ) {
                $src = wp_get_attachment_image_src( $img['id'], $size );
                if ( $src ) {
                    $url = $src[0];
                } elseif ( isset( $img['src'] ) ) {
                    $url = $img['src'];
                }
            }
        }

        return apply_filters( 'llms_' . $this->get( 'question_type' ) . '_question_get_image', $url, $this );

    }

    /**
     * Retrieve the next marker for question choices.
     *
     * @since 3.16.0
     * @since 3.30.1 Fixed bug which caused the next marker to be 1 index too high.
     * @since 7.4.1 Check `$type['choices']` is an array before trying to access it as such.
     *
     * @return string
     */
    protected function get_next_choice_marker() {
        $next_index = count( $this->get_choices( 'ids', false ) );
        $type       = $this->get_question_type();
        if ( ! is_array( $type['choices'] ?? false ) ) {
            return false;
        }
        $markers = $type['choices']['markers'];
        return $next_index > count( $markers ) ? false : $markers[ $next_index ];
    }

    /**
     * Retrieve question type data for the given question
     *
     * @since 3.16.0
     *
     * @return array
     */
    public function get_question_type() {
        return llms_get_question_type( $this->get( 'question_type' ) );
    }

    /**
     * Retrieve an instance of the questions parent LLMS_Quiz
     *
     * @since 3.16.0
     *
     * @return obj
     */
    public function get_quiz() {
        return new LLMS_Quiz( $this->get( 'parent_id' ) );
    }

    /**
     * Retrieve video embed for question featured video
     *
     * @since 3.16.0
     * @since 3.17.0 Unknown.
     *
     * @return string
     */
    public function get_video() {

        $html  = '';
        $embed = $this->get( 'video_src' );

        if ( $embed ) {

            // Get oembed.
            $html = wp_oembed_get( $embed );

            // Fallback to video shortcode.
            if ( ! $html ) {
                $html = do_shortcode( '[video src="' . $embed . '"]' );
            }
        }

        return apply_filters( 'llms_' . $this->get( 'question_type' ) . '_question_get_video', $html, $embed, $this );

    }

    /**
     * Attempt to grade a question
     *
     * @since 3.16.0
     * @since 3.16.15 Unknown.
     * @since 4.4.0 Combined nested if statements into a single condition.
     *
     * @param array[] $answer Selected answer(s).
     * @return string|null Returns `null` if the question cannot be automatically graded.
     *                     Returns `yes` for correct answers and `no` for incorrect answers.
     */
    public function grade( $answer ) {

        $question_type = $this->get( 'question_type' );

        /**
         * Use this filter to bypass core grading for a given question type.
         *
         * If the filter returns a non-null value core grading is bypassed.
         *
         * The dynamic portion of this hook, `$question_type`, refers to the type of question being graded.
         *
         * @since 3.16.0
         *
         * @param null|string   $grade    Defaults to `null` which signifies that LifterLMS should attempt to grade the answer.
         *                                Return `yes` (correct) or `no` (incorrect) to bypass core grading methods.
         * @param string[]      $answer   User-submitted answers.
         * @param LLMS_Question $question Question object.
         */
        $grade = apply_filters( "llms_{$question_type}_question_pre_grade", null, $answer, $this );

        if ( is_null( $grade ) && $this->get( 'points' ) >= 1 ) {

            $grading_type = $this->get_auto_grade_type();

            if ( 'choices' === $grading_type ) {

                sort( $answer );
                $grade = ( $answer === $this->get_correct_choice() ) ? 'yes' : 'no';

            } elseif ( 'conditional' === $grading_type ) {

                $correct = $this->get_conditional_correct_value();

                /**
                 * Filter whether or not conditionally graded question answers are treated as a case-sensitive
                 *
                 * By default, case sensitivity is disabled.
                 *
                 * @since 3.16.15
                 *
                 * @param boolean       $case_sensitive Whether or not answers are treated as case-sensitive.
                 * @param string[]      $answer         User-submitted answers.
                 * @param string[]      $correct        Correct answers.
                 * @param LLMS_Question $question       Question object.
                 */
                if ( false === apply_filters( 'llms_quiz_grading_case_sensitive', false, $answer, $correct, $this ) ) {

                    $answer  = array_map( 'strtolower', $answer );
                    $correct = array_map( 'strtolower', $correct );

                }

                $grade = ( $answer === $correct ) ? 'yes' : 'no';

            }
        }

        /**
         * Filter the grading result of an answer for a given question type.
         *
         * The dynamic portion of this hook, `$question_type`, refers to the type of question being graded.
         *
         * @since 3.16.0
         *
         * @param null|string   $grade    Defaults to `null` which signifies that LifterLMS should attempt to grade the answer.
         *                                Return `yes` (correct) or `no` (incorrect) to bypass core grading methods.
         * @param string[]      $answer   User-submitted answers.
         * @param LLMS_Question $question Question object.
         */
        return apply_filters( "llms_{$question_type}_question_grade", $grade, $answer, $this );

    }

    /**
     * Determine if a description is enabled and not empty
     *
     * @since 3.16.0
     * @since 3.16.12 Unknown.
     *
     * @return bool
     */
    public function has_description() {
        $enabled = $this->get( 'description_enabled' );
        $content = $this->get( 'content' );
        return ( 'yes' === $enabled && $content );
    }

    /**
     * Determine if a featured image is enabled and not empty
     *
     * @since 3.16.0
     *
     * @return bool
     */
    public function has_image() {
        $img = $this->get( 'image' );
        if ( is_array( $img ) ) {
            if ( ! empty( $img['enabled'] ) && ( ! empty( $img['id'] ) || ! empty( $img['src'] ) ) ) {
                return ( 'yes' === $img['enabled'] );
            }
        }
        return false;
    }

    /**
     * Determine if a featured video is enabled & not empty
     *
     * @since 3.16.0
     * @since 3.16.12 Unknown.
     *
     * @return bool
     */
    public function has_video() {
        $enabled = $this->get( 'video_enabled' );
        $src     = $this->get( 'video_src' );
        return ( 'yes' === $enabled && $src );
    }

    /**
     * Determine if the question is an orphan
     *
     * @since 3.27.0
     *
     * @return bool
     */
    public function is_orphan() {

        $statuses  = array( 'publish', 'draft' );
        $parent_id = $this->get( 'parent_id' );

        if ( ! $parent_id ) {
            return true;
        } elseif ( ! in_array( get_post_status( $parent_id ), $statuses, true ) ) {
            return true;
        }

        return false;

    }

    /**
     * Access question manager (used for question groups)
     *
     * @since 3.16.0
     *
     * @todo Need to prevent access for non-group questions.
     *
     * @return obj
     */
    public function questions() {
        return new LLMS_Question_Manager( $this );
    }

    /**
     * Sort choices by marker.
     *
     * @since 3.30.1
     * @since 4.4.0 Don't allow objects when using `unserialize()`.
     *
     * @param string $choice_a Serialized choice data.
     * @param string $choice_b Serialized choice data.
     * @return int
     */
    private function sort_choices( $choice_a, $choice_b ) {
        $a_data = unserialize( $choice_a->data, array( 'allowed_classes' => false ) ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize
        $b_data = unserialize( $choice_b->data, array( 'allowed_classes' => false ) ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize
        return strnatcmp( $a_data['marker'], $b_data['marker'] );
    }

    /**
     * Determine if the question supports a question feature
     *
     * @since 3.16.0
     * @since 3.16.15 Unknown.
     *
     * @param string $feature Name of the feature (eg "choices").
     * @param mixed  $option  Allow matching feature options.
     * @return boolean
     */
    public function supports( $feature, $option = null ) {

        $ret = false;

        $type = $this->get_question_type();
        if ( $type ) {
            if ( 'choices' === $feature ) {
                $ret = ( ! empty( $type['choices'] ) );
            } elseif ( 'grading' === $feature ) {
                $ret = ( $type['grading'] && $option === $type['grading'] );
            } elseif ( 'points' === $feature ) {
                $ret = $type['points'];
            } elseif ( 'random_lock' === $feature ) {
                $ret = $type['random_lock'];
            } elseif ( 'selectable' === $feature ) {
                $ret = empty( $type['choices'] ) ? false : $type['choices']['selectable'];
            }
        }

        /**
         * Filter supported features of a given question type.
         *
         * The dynamic portion of this hook, `$this->get( 'question_type' )`, refers to the type of question
         * being filtered.
         *
         * @since 3.16.0
         *
         * @param boolean       $ret      Return value.
         * @param string        $string   Name of the feature being checked.
         * @param string        $option   Name of the option being checked.
         * @param LLMS_Question $question Instance of the LLMS_Question.
         */
        return apply_filters( "llms_{$this->get( 'question_type' )}_question_supports", $ret, $feature, $option, $this );

    }

    /**
     * Called before data is sorted and returned by $this->toArray()
     *
     * Extending classes should override this data if custom data should
     * be added when object is converted to an array or json.
     *
     * @since 3.3.0
     * @since 3.16.0 Unknown.
     *
     * @param array $arr Array of data to be serialized.
     * @return array
     */
    protected function toArrayAfter( $arr ) {

        unset( $arr['author'] );
        unset( $arr['date'] );
        unset( $arr['excerpt'] );
        unset( $arr['modified'] );
        unset( $arr['status'] );

        $choices = array();
        foreach ( $this->get_choices() as $choice ) {
            $choices[] = $choice->get_data();
        }
        $arr['choices'] = $choices;

        if ( 'group' === $this->get( 'question_type' ) ) {
            $arr['questions'] = array();
            foreach ( $this->get_questions() as $question ) {
                $arr['questions'][] = $question->toArray();
            }
        }

        return $arr;

    }

    /**
     * Update a question choice
     *
     * If no id is supplied will create a new choice.
     *
     * @since 3.16.0
     *
     * @param array $data Array of choice data.
     * @return string|boolean
     */
    public function update_choice( $data ) {

        // If there's no ID, we'll add a new choice.
        if ( ! isset( $data['id'] ) ) {
            return $this->create_choice( $data );
        }

        // Get the question.
        $choice = $this->get_choice( $data['id'] );
        if ( ! $choice ) {
            return false;
        }

        $choice->update( $data )->save();

        // Return choice ID.
        return $choice->get( 'id' );

    }

    /**
     * Retrieve quizzes this quiz is assigned to
     *
     * @since 3.12.0
     *
     * @return array Array of WP_Post IDs (quiz post types).
     */
    public function get_quizzes() {

        $id  = absint( $this->get( 'id' ) );
        $len = strlen( strval( $id ) );

        $str_like = '%' . sprintf( 's:2:"id";s:%1$d:"%2$s";', $len, $id ) . '%';
        $int_like = '%' . sprintf( 's:2:"id";i:%1$s;', $id ) . '%';

        global $wpdb;
        // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
        $query = $wpdb->get_col( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
            "SELECT post_id
             FROM {$wpdb->postmeta}
             WHERE meta_key = '_llms_questions'
               AND (
                         meta_value LIKE '{$str_like}'
                      OR meta_value LIKE '{$int_like}'
               );"
        );
        // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared

        return $query;

    }

    /**
     * Don't add custom fields during toArray()
     *
     * @since 3.16.11
     *
     * @param array $arr Post model array.
     * @return array
     */
    protected function toArrayCustom( $arr ) {
        return $arr;
    }

}