文件操作 - Quiz.php
返回文件管理
返回主菜单
删除本文件
文件: /home/www/public/wp-content/plugins/tutor/classes/Quiz.php
编辑文件内容
<?php /** * Quiz class * * @package Tutor\QuestionAnswer * @author Themeum <support@themeum.com> * @link https://themeum.com * @since 1.0.0 */ namespace TUTOR; defined( 'ABSPATH' ) || exit; use Tutor\Components\Constants\Size; use Tutor\Components\Constants\Variant; use Tutor\Components\Button; use Tutor\Components\ConfirmationModal; use Tutor\Components\Modal; use Tutor\Components\SvgIcon; use Tutor\Components\Table; use Tutor\Helpers\HttpHelper; use Tutor\Helpers\QueryHelper; use Tutor\Models\CourseModel; use Tutor\Models\QuizModel; use Tutor\Traits\JsonResponse; use WP_Post; /** * Manage quiz operations. * * @since 1.0.0 */ class Quiz { use JsonResponse; /** * Quiz post type * * @since 4.0.0 * * @var string */ private $post_type; const META_QUIZ_OPTION = 'tutor_quiz_option'; /** * Quiz feedback mode: show result after the attempt. * * @since 4.0.0 */ const QUIZ_FEEDBACK_MODE_REVEAL = 'reveal'; /** * Quiz feedback mode: reattempt quiz any number of times. * * @since 4.0.0 */ const QUIZ_FEEDBACK_MODE_RETRY = 'retry'; /** * Quiz feedback mode: answers shown after quiz is finished. * * @since 4.0.0 */ const QUIZ_FEEDBACK_MODE_DEFAULT = 'default'; /** * URL Query param * * @since 4.0.0 */ const ACTION_VIEW_DETAILS = 'view_details'; /** * Allowed attrs * * @var array */ private $allowed_attributes = array( 'src' => array(), 'style' => array(), 'class' => array(), 'id' => array(), 'href' => array(), 'alt' => array(), 'title' => array(), 'type' => array(), 'controls' => array(), 'muted' => array(), 'loop' => array(), 'poster' => array(), 'preload' => array(), 'autoplay' => array(), 'width' => array(), 'height' => array(), ); /** * Allowed HTML tags * * @var array */ private $allowed_html = array( 'img', 'b', 'i', 'br', 'a', 'audio', 'video', 'source' ); /** * Register hooks * * @since 1.0.0 * @since 4.0.0 $register_hooks param added * * @param bool $register_hooks To register hooks. * * @return void */ public function __construct( $register_hooks = true ) { $this->post_type = tutor()->quiz_post_type; $this->prepare_allowed_html(); if ( ! $register_hooks ) { return; } add_action( 'wp_ajax_tutor_quiz_timeout', array( $this, 'tutor_quiz_timeout' ) ); // User take the quiz. add_action( 'template_redirect', array( $this, 'start_the_quiz' ) ); add_action( 'template_redirect', array( $this, 'answering_quiz' ) ); add_action( 'template_redirect', array( $this, 'finishing_quiz_attempt' ) ); /** * Instructor quiz review and feedback Ajax API. */ add_action( 'wp_ajax_review_quiz_answer', array( $this, 'review_quiz_answer' ) ); add_action( 'wp_ajax_tutor_review_quiz_answers', array( $this, 'review_quiz_answers' ) ); add_action( 'wp_ajax_tutor_instructor_feedback', array( $this, 'tutor_instructor_feedback' ) ); /** * New quiz builder Ajax API. */ add_action( 'wp_ajax_tutor_quiz_details', array( $this, 'ajax_quiz_details' ) ); add_action( 'wp_ajax_tutor_quiz_delete', array( $this, 'ajax_quiz_delete' ) ); /** * Frontend Stuff */ add_action( 'wp_ajax_tutor_render_quiz_content', array( $this, 'tutor_render_quiz_content' ) ); /** * Quiz abandon action * * @since 1.9.6 */ add_action( 'wp_ajax_tutor_quiz_abandon', array( $this, 'tutor_quiz_abandon' ) ); /** * Delete quiz attempt * * @since 2.1.0 */ add_action( 'wp_ajax_tutor_attempt_delete', array( $this, 'attempt_delete' ) ); add_action( 'tutor_quiz/answer/review/after', array( $this, 'do_auto_course_complete' ), 10, 3 ); // Add quiz title as nav item & render single content on the learning area. add_action( "tutor_learning_area_nav_item_{$this->post_type}", array( $this, 'render_nav_item' ), 10, 2 ); add_action( "tutor_single_content_{$this->post_type}", array( $this, 'render_single_content' ) ); /** * Slugs listed in tutor_quiz_templates_not_in_core have no file under wp-content/plugins/tutor/templates/. * Without this, tutor_load_template() still runs from generic quiz templates and tutor_get_template() * prints "The file you are trying to load does not exist…". Returning false here exits before that lookup. * Add-ons ship their own files and load them outside this path (e.g. direct include from the add-on). * * @since 4.0.0 */ add_filter( 'should_tutor_load_template', array( $this, 'skip_addon_only_question_partials' ), 5, 3 ); } /** * Skip loading templates that are not packaged with core Tutor LMS. * * @since 4.0.0 * * @param bool $load Whether to load the template. * @param string $template Template name in dot notation. * @param array $variables Template variables. * * @return bool */ public function skip_addon_only_question_partials( $load, $template, $variables ) { $addons_only = apply_filters( 'tutor_quiz_templates_not_in_core', array( 'learning-area.quiz.questions.draw-image', 'learning-area.quiz.questions.scale', 'learning-area.quiz.questions.pin-image', 'learning-area.quiz.questions.coordinates', 'learning-area.quiz.questions.puzzle', 'shared.components.quiz.attempt-details.questions.draw-image', 'shared.components.quiz.attempt-details.questions.scale', 'shared.components.quiz.attempt-details.questions.pin-image', 'shared.components.quiz.attempt-details.questions.coordinates', 'shared.components.quiz.attempt-details.questions.puzzle', ), $template, $variables ); if ( in_array( $template, $addons_only, true ) ) { return false; } return $load; } /** * Get quiz time units options. * * @since 2.6.0 * * @return array */ public static function quiz_time_units() { $time_units = array( 'seconds' => __( 'Seconds', 'tutor' ), 'minutes' => __( 'Minutes', 'tutor' ), 'hours' => __( 'Hours', 'tutor' ), 'days' => __( 'Days', 'tutor' ), 'weeks' => __( 'Weeks', 'tutor' ), ); return apply_filters( 'tutor_quiz_time_units', $time_units ); } /** * Get quiz default settings. * * @since 3.0.0 * * @return array */ public static function get_default_quiz_settings() { $settings = array( 'time_limit' => array( 'time_type' => 'minutes', 'time_value' => 0, ), 'attempts_allowed' => 10, 'answers_reveal_duration' => 5, 'auto_start_delay' => 5, 'enable_answer_reveal' => '0', 'enable_pagination' => '0', 'hide_question_number_overview' => 0, 'hide_previous_button' => '0', 'hide_quiz_time_display' => 0, 'limit_attempts_allowed' => '0', 'max_questions_for_answer' => 10, 'open_ended_answer_characters_limit' => 500, 'pass_is_required' => 0, 'pagination_type' => 'shape', 'passing_grade' => 80, 'question_layout_view' => '', 'questions_order' => 'rand', 'quiz_auto_start' => 0, 'short_answer_characters_limit' => 200, ); return apply_filters( 'tutor_quiz_default_settings', $settings ); } /** * Normalize quiz settings. * * V4 stores reveal and pagination as explicit flags, but older quizzes may * still contain legacy keys. * * @since 4.0.0 * * @param array $settings Quiz settings. * * @return array */ public static function normalize_quiz_settings( array $settings ): array { $defaults = self::get_default_quiz_settings(); $has_enable_pagination = array_key_exists( 'enable_pagination', $settings ); $has_enable_answer_reveal = array_key_exists( 'enable_answer_reveal', $settings ); $has_limit_attempts_allowed = array_key_exists( 'limit_attempts_allowed', $settings ); $settings = wp_parse_args( $settings, $defaults ); $settings['time_limit'] = wp_parse_args( is_array( $settings['time_limit'] ?? null ) ? $settings['time_limit'] : array(), $defaults['time_limit'] ); $question_layout_view = (string) ( $settings['question_layout_view'] ?? '' ); $question_layout_view = '' !== $question_layout_view ? $question_layout_view : 'single_question'; $supported_pagination_types = array( 'shape', 'radio', 'number' ); $pagination_type = $settings['pagination_type'] ?? $settings['question_pagination_style'] ?? 'shape'; $pagination_type = in_array( $pagination_type, $supported_pagination_types, true ) ? $pagination_type : 'shape'; $enable_pagination = $has_enable_pagination ? '1' === (string) $settings['enable_pagination'] : 'question_pagination' === $question_layout_view; $enable_answer_reveal = $has_enable_answer_reveal ? '1' === (string) $settings['enable_answer_reveal'] : self::QUIZ_FEEDBACK_MODE_REVEAL === (string) ( $settings['feedback_mode'] ?? '' ); $limit_attempts_allowed = $has_limit_attempts_allowed ? '1' === (string) $settings['limit_attempts_allowed'] : self::QUIZ_FEEDBACK_MODE_RETRY === (string) ( $settings['feedback_mode'] ?? '' ); if ( 'question_pagination' === $question_layout_view ) { $question_layout_view = 'single_question'; $enable_pagination = true; } if ( 'question_below_each_other' === $question_layout_view ) { $settings['hide_question_number_overview'] = '0'; $enable_answer_reveal = false; } if ( $enable_pagination ) { $settings['hide_previous_button'] = '0'; } $settings['question_layout_view'] = $question_layout_view; $settings['enable_pagination'] = $enable_pagination ? '1' : '0'; $settings['pagination_type'] = $pagination_type; $settings['enable_answer_reveal'] = $enable_answer_reveal ? '1' : '0'; $settings['limit_attempts_allowed'] = $limit_attempts_allowed ? '1' : '0'; unset( $settings['feedback_mode'], $settings['question_pagination_style'] ); return apply_filters( 'tutor_quiz_normalized_settings', $settings ); } /** * Determine if quiz retry is allowed for the current attempt count. * * @since 4.0.0 * * @param mixed $limit_attempts_allowed If attempts are limited. * @param int $attempts_allowed Allowed attempts. * @param int $attempted_count Attempted count. * * @return bool */ public static function can_retry_quiz( $limit_attempts_allowed, int $attempts_allowed, int $attempted_count ): bool { $limit_attempts_allowed = '1' === (string) $limit_attempts_allowed; if ( ! $limit_attempts_allowed ) { return false; } return 0 === $attempts_allowed || $attempted_count < $attempts_allowed; } /** * Get effective attempt count for quiz display/runtime. * * @since 4.0.0 * * @param bool $limit_attempts_allowed If attempts are limited. * @param int $attempts_allowed Allowed attempts. * * @return int */ public static function get_effective_attempts_allowed( bool $limit_attempts_allowed, int $attempts_allowed ): int { return $limit_attempts_allowed ? $attempts_allowed : 1; } /** * Get question default settings. * * @since 3.0.0 * * @param string $type type of question. * * @return array */ public static function get_default_question_settings( $type ) { $settings = array( 'question_type' => $type, 'question_mark' => 1, 'answer_required' => 0, 'randomize_question' => 0, 'show_question_mark' => 0, ); return apply_filters( 'tutor_question_default_settings', $settings ); } /** * Get quiz modes * * @since 2.6.0 * * @return array */ public static function quiz_modes() { $modes = array( array( 'key' => 'default', 'value' => __( 'Default', 'tutor' ), 'description' => __( 'Answers shown after quiz is finished', 'tutor' ), ), array( 'key' => 'reveal', 'value' => __( 'Reveal Mode', 'tutor' ), 'description' => __( 'Show result after the attempt.', 'tutor' ), ), array( 'key' => 'retry', 'value' => __( 'Retry Mode', 'tutor' ), 'description' => __( 'Reattempt quiz any number of times. Define Attempts Allowed below.', 'tutor' ), ), ); return apply_filters( 'tutor_quiz_modes', $modes ); } /** * Get quiz modes * * @since 2.6.0 * * @return array */ public static function quiz_question_layouts() { $layouts = array( '' => __( 'Set question layout view', 'tutor' ), 'single_question' => __( 'Single Question', 'tutor' ), 'question_pagination' => __( 'Question Pagination', 'tutor' ), 'question_below_each_other' => __( 'Question below each other', 'tutor' ), ); return apply_filters( 'tutor_quiz_layouts', $layouts ); } /** * Get quiz modes * * @since 2.6.0 * * @return array */ public static function quiz_question_orders() { $orders = array( 'rand' => __( 'Random', 'tutor' ), 'sorting' => __( 'Sorting', 'tutor' ), 'asc' => __( 'Ascending', 'tutor' ), 'desc' => __( 'Descending', 'tutor' ), ); return apply_filters( 'tutor_quiz_layouts', $orders ); } /** * Prepare allowed HTML * * @since 1.0.0 * * @return void */ private function prepare_allowed_html() { $allowed = array(); foreach ( $this->allowed_html as $tag ) { $allowed[ $tag ] = $this->allowed_attributes; } $this->allowed_html = $allowed; } /** * Instructor feedback ajax request handler * * @since 1.0.0 * * @return void | send json response */ public function tutor_instructor_feedback() { tutor_utils()->checking_nonce(); // Check if user is privileged. if ( ! User::has_any_role( array( User::ADMIN, User::INSTRUCTOR ) ) ) { wp_send_json_error( tutor_utils()->error_message() ); } $attempt_details = self::attempt_details( Input::post( 'attempt_id', 0, Input::TYPE_INT ) ); $feedback = Input::post( 'feedback', '', Input::TYPE_KSES_POST ); $attempt_info = isset( $attempt_details->attempt_info ) ? $attempt_details->attempt_info : false; $course_id = tutor_utils()->avalue_dot( 'course_id', $attempt_details, 0 ); $is_instructor = tutor_utils()->is_instructor_of_this_course( get_current_user_id(), $course_id ); if ( ! current_user_can( 'manage_options' ) && ! $is_instructor ) { wp_send_json_error( tutor_utils()->error_message() ); } if ( $attempt_info ) { //phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize $unserialized = unserialize( $attempt_details->attempt_info ); if ( is_array( $unserialized ) ) { $unserialized['instructor_feedback'] = $feedback; //phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize $update = self::update_attempt_info( $attempt_details->attempt_id, serialize( $unserialized ) ); if ( $update ) { do_action( 'tutor_quiz/attempt/submitted/feedback', $attempt_details->attempt_id ); wp_send_json_success(); } else { wp_send_json_error(); } } else { wp_send_json_error( __( 'Invalid quiz info', 'tutor' ) ); } } wp_send_json_error(); } /** * Start Quiz from here... * * @since 1.0.0 * * @return void */ public function start_the_quiz() { if ( 'tutor_start_quiz' !== Input::post( 'tutor_action' ) ) { return; } tutor_utils()->checking_nonce(); if ( ! is_user_logged_in() ) { wp_die( esc_html__( 'Please sign in to do this operation', 'tutor' ) ); } $user_id = get_current_user_id(); $quiz_id = Input::post( 'quiz_id', 0, Input::TYPE_INT ); $course = CourseModel::get_course_by_quiz( $quiz_id ); if ( ! $course ) { wp_die( esc_html__( 'Invalid course', 'tutor' ) ); } // Check & die if don't have quiz access. QuizModel::has_quiz_access( $quiz_id, $course->id ); self::quiz_attempt( $course->ID, $quiz_id, $user_id ); wp_safe_redirect( get_permalink( $quiz_id ) ); die(); } /** * Manage quiz attempt * * @since 2.6.1 * * @param integer $course_id course id. * @param integer $quiz_id quiz id. * @param integer $user_id user id. * @param string $attempt_status attempt status. * * @return int inserted id|0 */ public static function quiz_attempt( int $course_id, int $quiz_id, int $user_id, $attempt_status = QuizModel::ATTEMPT_STARTED ) { global $wpdb; if ( ! $course_id ) { wp_die( 'There is something went wrong with course, please check if quiz attached with a course' ); } // Check & die if don't have quiz access. QuizModel::has_quiz_access( $quiz_id, $course_id ); do_action( 'tutor_quiz/start/before', $quiz_id, $user_id ); $date = date( 'Y-m-d H:i:s', tutor_time() ); //phpcs:ignore $tutor_quiz_option = (array) tutor_utils()->get_quiz_option( $quiz_id ); $time_limit = tutor_utils()->get_quiz_option( $quiz_id, 'time_limit.time_value' ); $time_limit_seconds = 0; $time_type = 'seconds'; if ( $time_limit ) { $time_type = tutor_utils()->get_quiz_option( $quiz_id, 'time_limit.time_type' ); switch ( $time_type ) { case 'seconds': $time_limit_seconds = $time_limit; break; case 'minutes': $time_limit_seconds = $time_limit * 60; break; case 'hours': $time_limit_seconds = $time_limit * 60 * 60; break; case 'days': $time_limit_seconds = $time_limit * 60 * 60 * 24; break; case 'weeks': $time_limit_seconds = $time_limit * 60 * 60 * 24 * 7; break; } } $max_question_allowed = tutor_utils()->max_questions_for_take_quiz( $quiz_id ); $tutor_quiz_option['time_limit']['time_limit_seconds'] = $time_limit_seconds; $attempt_data = array( 'course_id' => $course_id, 'quiz_id' => $quiz_id, 'user_id' => $user_id, 'total_questions' => $max_question_allowed, 'total_answered_questions' => 0, 'attempt_info' => maybe_serialize( $tutor_quiz_option ), 'attempt_status' => $attempt_status, 'attempt_ip' => tutor_utils()->get_ip(), 'attempt_started_at' => $date, ); $wpdb->insert( $wpdb->prefix . 'tutor_quiz_attempts', $attempt_data ); $attempt_id = (int) $wpdb->insert_id; if ( $attempt_id ) { do_action( 'tutor_quiz/start/after', $quiz_id, $user_id, $attempt_id ); return $attempt_id; } else { return 0; } } /** * Answering quiz * * @since 1.0.0 * * @return void */ public function answering_quiz() { if ( 'tutor_answering_quiz_question' !== Input::post( 'tutor_action' ) ) { return; } self::tutor_quiz_attempt_submit(); wp_safe_redirect( get_the_permalink() ); die(); } /** * Quiz abandon submission handler * * @since 1.9.6 * * @return void JSON response */ public function tutor_quiz_abandon() { if ( 'tutor_answering_quiz_question' !== Input::post( 'tutor_action' ) ) { return; } tutor_utils()->checking_nonce(); // submit quiz attempts. if ( self::tutor_quiz_attempt_submit() ) { wp_send_json_success(); } else { wp_send_json_error(); } } /** * Validate quiz attempt * * @since 3.9.13 * * @param int $attempt_id attempt id. * @param int $user_id user id. * * @return object|false attempt object if valid otherwise false */ private static function validate_attempt( $attempt_id, $user_id ) { $attempt = tutor_utils()->get_attempt( $attempt_id ); if ( ! $attempt || ! is_object( $attempt ) || (int) $attempt->user_id !== (int) $user_id ) { return false; } return $attempt; } /** * This is a unified method for handling normal quiz submit or abandon submit * It will handle ajax or normal form submit and can be used with different hooks * * @since 1.9.6 * * @return bool true if quiz attempt submit successfully otherwise false */ public static function tutor_quiz_attempt_submit() { if ( ! is_user_logged_in() ) { die( 'Please sign in to do this operation' ); } tutor_utils()->checking_nonce(); $user_id = get_current_user_id(); $attempt_id = Input::post( 'attempt_id', 0, Input::TYPE_INT ); $attempt = self::validate_attempt( $attempt_id, $user_id ); if ( ! $attempt ) { die( 'Operation not allowed, attempt not found or permission denied' ); } // Check & die if don't have quiz access. $quiz_id = Input::post( 'quiz_id', 0, Input::TYPE_INT ); QuizModel::has_quiz_access( $quiz_id ); if ( QuizModel::ATTEMPT_TIMEOUT === $attempt->attempt_status ) { return false; } // Sanitize data by helper method. $attempt_answers = isset( $_POST['attempt'] ) ? tutor_sanitize_data( $_POST['attempt'] ) : false; //phpcs:ignore $attempt_answers = is_array( $attempt_answers ) ? $attempt_answers : array(); self::manage_attempt_answers( $attempt_answers, $attempt, $attempt_id, $attempt->course_id, $user_id ); return true; } /** * Manage attempt answers * * Evaluate each attempt answer and update the attempts table & insert in the attempt_answers table. * * @since 2.6.1 * * @param array $attempt_answers attempt answers. * @param object $attempt single attempt. * @param int $attempt_id attempt id. * @param int $course_id course id. * @param int $user_id user id. * * @return void */ public static function manage_attempt_answers( $attempt_answers, $attempt, $attempt_id, $course_id, $user_id ) { if ( ! is_array( $attempt_answers ) || ! self::validate_attempt( $attempt_id, $user_id ) ) { return; } global $wpdb; // Before hook. do_action( 'tutor_quiz/attempt_analysing/before', $attempt_id ); // Single quiz can have multiple question. So multiple answer should be saved. foreach ( $attempt_answers as $posted_attempt_id => $attempt_answer ) { if ( ! self::validate_attempt( $posted_attempt_id, $user_id ) ) { continue; } // Get total marks of all question comes. $question_ids = tutor_utils()->avalue_dot( 'quiz_question_ids', $attempt_answer ); $question_ids = array_filter( $question_ids, fn ( $id ) => is_numeric( $id ) && intval( $id ) > 0 ); // Calculate and set the total marks in attempt table for this question. if ( tutor_utils()->count( $question_ids ) ) { $question_ids_string = QueryHelper::prepare_in_clause( $question_ids ); // Get total marks of the questions from question table. //phpcs:disable $query = $wpdb->prepare( "SELECT SUM(question_mark) FROM {$wpdb->prefix}tutor_quiz_questions WHERE 1 = %d AND question_id IN({$question_ids_string}); ", 1 ); $total_question_marks = $wpdb->get_var( $query ); //phpcs:enable $total_question_marks = apply_filters( 'tutor_filter_update_before_question_mark', $total_question_marks, $question_ids, $user_id, $attempt_id ); // Set the the total mark in the attempt table for the question. $wpdb->update( $wpdb->prefix . 'tutor_quiz_attempts', array( 'total_marks' => $total_question_marks ), array( 'attempt_id' => $attempt_id ) ); } $total_marks = 0; $review_required = false; $quiz_answers = tutor_utils()->avalue_dot( 'quiz_question', $attempt_answer ); if ( tutor_utils()->count( $quiz_answers ) ) { foreach ( $quiz_answers as $question_id => $answers ) { $question = QuizModel::get_quiz_question_by_id( $question_id ); $question_type = $question->question_type; $is_answer_was_correct = false; $given_answer = ''; if ( QuizModel::QUESTION_TYPE_TRUE_FALSE === $question_type || QuizModel::QUESTION_TYPE_SINGLE_CHOICE === $question_type ) { $given_answer = $answers; $is_answer_was_correct = (bool) $wpdb->get_var( $wpdb->prepare( "SELECT is_correct FROM {$wpdb->prefix}tutor_quiz_question_answers WHERE answer_id = %d ", $answers ) ); } elseif ( QuizModel::QUESTION_TYPE_MULTIPLE_CHOICE === $question_type ) { $given_answer = (array) ( $answers ); $given_answer = array_filter( $given_answer, fn ( $id ) => is_numeric( $id ) && intval( $id ) > 0 ); $get_original_answers = (array) $wpdb->get_col( $wpdb->prepare( "SELECT answer_id FROM {$wpdb->prefix}tutor_quiz_question_answers WHERE belongs_question_id = %d AND belongs_question_type = %s AND is_correct = 1 ; ", $question->question_id, $question_type ) ); if ( count( array_diff( $get_original_answers, $given_answer ) ) === 0 && count( $get_original_answers ) === count( $given_answer ) ) { $is_answer_was_correct = true; } $given_answer = maybe_serialize( $given_answer ); } elseif ( QuizModel::QUESTION_TYPE_FILL_IN_THE_BLANK === $question_type ) { $get_original_answer = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$wpdb->prefix}tutor_quiz_question_answers WHERE belongs_question_id = %d AND belongs_question_type = %s ; ", $question->question_id, $question_type ) ); /** * Answers stored in DB */ $gap_answer = (array) explode( '|', $get_original_answer->answer_two_gap_match ); $gap_answer = maybe_serialize( array_map( function ( $ans ) { return wp_slash( trim( $ans ) ); }, $gap_answer ) ); /** * Answers from user input */ $given_answer = is_array( $answers ) ? array_map( 'sanitize_text_field', $answers ) : array( sanitize_text_field( $answers ) ); $given_answer = maybe_serialize( $given_answer ); /** * Compare answer's by making both case-insensitive. */ if ( strtolower( $given_answer ) === strtolower( $gap_answer ) ) { $is_answer_was_correct = true; } } elseif ( QuizModel::QUESTION_TYPE_OPEN_ENDED === $question_type || QuizModel::QUESTION_TYPE_SHORT_ANSWER === $question_type ) { $review_required = true; $given_answer = wp_kses_post( $answers ); } elseif ( QuizModel::QUESTION_TYPE_ORDERING === $question_type || QuizModel::QUESTION_TYPE_MATCHING === $question_type || QuizModel::QUESTION_TYPE_IMAGE_MATCHING === $question_type ) { $answers = (array) tutor_utils()->avalue_dot( 'answers', $answers ); $given_answer = (array) array_map( 'sanitize_text_field', $answers ); $given_answer = maybe_serialize( $given_answer ); $get_original_answers = (array) $wpdb->get_col( $wpdb->prepare( "SELECT answer_id FROM {$wpdb->prefix}tutor_quiz_question_answers WHERE belongs_question_id = %d AND belongs_question_type = %s ORDER BY answer_order ASC ; ", $question->question_id, $question_type ) ); $get_original_answers = array_map( 'sanitize_text_field', $get_original_answers ); if ( maybe_serialize( $get_original_answers ) == $given_answer ) { $is_answer_was_correct = true; } } elseif ( QuizModel::QUESTION_TYPE_IMAGE_ANSWERING === $question_type ) { $image_inputs = tutor_utils()->avalue_dot( 'answer_id', $answers ); $image_inputs = (array) array_map( 'sanitize_text_field', $image_inputs ); $given_answer = maybe_serialize( $image_inputs ); $stored_answers = $wpdb->get_results( $wpdb->prepare( "SELECT answer_id, answer_title FROM {$wpdb->prefix}tutor_quiz_question_answers WHERE belongs_question_id = %d AND belongs_question_type = %s ORDER BY answer_order ASC", $question_id, QuizModel::QUESTION_TYPE_IMAGE_ANSWERING ) ); $is_answer_was_correct = true; foreach ( $stored_answers as $stored_answer ) { $user_answer = isset( $image_inputs[ $stored_answer->answer_id ] ) ? trim( wp_unslash( $image_inputs[ $stored_answer->answer_id ] ) ) : ''; $correct_answer = trim( wp_unslash( $stored_answer->answer_title ) ); if ( 0 !== strcasecmp( $user_answer, $correct_answer ) ) { $is_answer_was_correct = false; break; } } } else { $custom_answer_data = array( 'given_answer' => $given_answer, 'is_answer_was_correct' => $is_answer_was_correct, ); $custom_answer_data = apply_filters( 'tutor_quiz_process_custom_question_answer', $custom_answer_data, $question_type, $answers, $question, $question_id, $attempt_id ); $given_answer = $custom_answer_data['given_answer']; $is_answer_was_correct = $custom_answer_data['is_answer_was_correct']; } $question_mark = $is_answer_was_correct ? $question->question_mark : 0; $total_marks += $question_mark; $answers_data = array( 'user_id' => $user_id, 'quiz_id' => $attempt->quiz_id, 'question_id' => $question_id, 'quiz_attempt_id' => $attempt_id, 'given_answer' => $given_answer, 'question_mark' => $question->question_mark, 'achieved_mark' => $question_mark, 'minus_mark' => 0, 'is_correct' => $is_answer_was_correct ? 1 : 0, ); /** * Check if question_type open ended or short ans the set * is_correct default value null before saving */ if ( in_array( $question_type, QuizModel::get_manual_review_types(), true ) ) { $answers_data['is_correct'] = null; $review_required = true; } $answers_data = apply_filters( 'tutor_filter_quiz_answer_data', $answers_data, $question_id, $question_type, $user_id, $attempt_id ); // Filter total marks after grading. Runs after answers_data is built and graded, // so add-ons (e.g. H5P, pin_image, draw_image) can add their achieved marks. $total_marks = apply_filters( 'tutor_filter_quiz_total_marks', $total_marks, $question_id, $question_type, $user_id, $attempt_id, $answers_data ); $wpdb->insert( $wpdb->prefix . 'tutor_quiz_attempt_answers', $answers_data ); } } $attempt_info = array( 'total_answered_questions' => tutor_utils()->count( $quiz_answers ), 'earned_marks' => $total_marks, 'attempt_status' => QuizModel::ATTEMPT_ENDED, 'attempt_ended_at' => date( 'Y-m-d H:i:s', tutor_time() ), //phpcs:ignore ); if ( $review_required ) { $attempt_info['attempt_status'] = QuizModel::REVIEW_REQUIRED; } $wpdb->update( $wpdb->tutor_quiz_attempts, $attempt_info, array( 'attempt_id' => $attempt_id ) ); QuizModel::update_attempt_result( $attempt_id ); } // After hook. do_action( 'tutor_quiz/attempt_ended', $attempt_id, $course_id, $user_id ); } /** * Quiz attempt will be finish here * * @since 1.0.0 * * @return void */ public function finishing_quiz_attempt() { if ( Input::post( 'tutor_action' ) !== 'tutor_finish_quiz_attempt' ) { return; } // Checking nonce. tutor_utils()->checking_nonce(); if ( ! is_user_logged_in() ) { die( 'Please sign in to do this operation' ); } global $wpdb; $quiz_id = Input::post( 'quiz_id', 0, Input::TYPE_INT ); $attempt = tutor_utils()->is_started_quiz( $quiz_id ); $attempt_id = $attempt->attempt_id; $attempt_info = array( 'total_answered_questions' => 0, 'earned_marks' => 0, 'attempt_status' => QuizModel::ATTEMPT_ENDED, 'attempt_ended_at' => date( 'Y-m-d H:i:s', tutor_time() ), //phpcs:ignore ); do_action( 'tutor_quiz_before_finish', $attempt_id, $quiz_id, $attempt->user_id ); $wpdb->update( $wpdb->prefix . 'tutor_quiz_attempts', $attempt_info, array( 'attempt_id' => $attempt_id ) ); do_action( 'tutor_quiz_finished', $attempt_id, $quiz_id, $attempt->user_id ); wp_redirect( tutor_utils()->input_old( '_wp_http_referer' ) ); } /** * Get quiz total marks. * * @since 3.0.0 * * @param int $quiz_id quiz id. * * @return int|float */ public static function get_quiz_total_marks( $quiz_id ) { global $wpdb; $total_marks = $wpdb->get_var( $wpdb->prepare( "SELECT SUM(question_mark) total_marks FROM {$wpdb->prefix}tutor_quiz_questions WHERE quiz_id=%d", $quiz_id ) ); return floatval( $total_marks ); } /** * Quiz timeout by ajax * * @since 1.0.0 * * @return void */ public function tutor_quiz_timeout() { tutils()->checking_nonce(); global $wpdb; $quiz_id = Input::post( 'quiz_id', 0, Input::TYPE_INT ); $attempt = tutor_utils()->is_started_quiz( $quiz_id ); if ( $attempt ) { $attempt_id = $attempt->attempt_id; $data = array( 'attempt_status' => QuizModel::ATTEMPT_TIMEOUT, 'total_marks' => self::get_quiz_total_marks( $quiz_id ), 'earned_marks' => 0, 'attempt_ended_at' => gmdate( 'Y-m-d H:i:s', tutor_time() ), ); $wpdb->update( $wpdb->prefix . 'tutor_quiz_attempts', $data, array( 'attempt_id' => $attempt->attempt_id ) ); do_action( 'tutor_quiz_timeout', $attempt_id, $quiz_id, $attempt->user_id ); wp_send_json_success(); } wp_send_json_error( __( 'Quiz has been timeout already', 'tutor' ) ); } /** * Review quiz answer * * @since 1.0.0 * * @return void */ public function review_quiz_answer() { tutor_utils()->checking_nonce(); $attempt_id = Input::post( 'attempt_id', 0, Input::TYPE_INT ); $context = Input::post( 'context' ); $attempt_answer_id = Input::post( 'attempt_answer_id', 0, Input::TYPE_INT ); $question_id = Input::post( 'question_id', 0, Input::TYPE_INT ); $mark_as = Input::post( 'mark_as' ); if ( ! tutor_utils()->can_user_manage( 'attempt', $attempt_id ) ) { wp_send_json_error( array( 'message' => __( 'Access Denied', 'tutor' ) ) ); } if ( $attempt_answer_id && ! tutor_utils()->can_user_manage( 'attempt_answer', $attempt_answer_id ) ) { wp_send_json_error( array( 'message' => __( 'Access Denied', 'tutor' ) ) ); } $attempt_answer = $this->resolve_attempt_answer_for_review( $attempt_id, $attempt_answer_id, $question_id ); $review_data = $attempt_answer ? $this->apply_quiz_answer_review( $attempt_id, $attempt_answer, $mark_as ) : null; if ( ! $review_data ) { wp_send_json_error( array( 'message' => __( 'Review update failed', 'tutor' ) ) ); } QuizModel::update_attempt_result( $attempt_id ); ob_start(); tutor_load_template_from_custom_path( tutor()->path . '/views/quiz/attempt-details.php', array( 'attempt_id' => $attempt_id, 'user_id' => $review_data['student_id'], 'context' => $context, 'back_url' => Input::post( 'back_url' ), ) ); wp_send_json_success( array( 'html' => ob_get_clean() ) ); } /** * Review quiz answers in bulk for v4 dashboard flow. * * @since 4.0.0 * * @return void */ public function review_quiz_answers() { tutor_utils()->checking_nonce(); $attempt_id = Input::post( 'attempt_id', 0, Input::TYPE_INT ); $review_statuses = Input::post( 'review_statuses', array(), Input::TYPE_ARRAY ); $this->review_quiz_answers_bulk( $attempt_id, $review_statuses ); } /** * Review quiz answers in bulk for v4 dashboard flow. * * @since 4.0.0 * * @param int $attempt_id Attempt ID. * @param array $review_statuses Review statuses keyed by question ID. * * @return void */ private function review_quiz_answers_bulk( int $attempt_id, array $review_statuses ) { if ( ! tutor_utils()->can_user_manage( 'attempt', $attempt_id ) ) { $this->response_fail( __( 'Access Denied', 'tutor' ), 403 ); } $attempt_answers = QuizModel::get_quiz_answers_by_attempt_id( $attempt_id ); $answers_by_question_id = array(); if ( is_array( $attempt_answers ) ) { foreach ( $attempt_answers as $attempt_answer ) { $question_id = (int) ( $attempt_answer->question_id ?? 0 ); if ( $question_id > 0 ) { $answers_by_question_id[ $question_id ] = $attempt_answer; } } } foreach ( $review_statuses as $question_id => $mark_as ) { $question_id = (int) $question_id; $mark_as = (string) $mark_as; if ( ! in_array( $mark_as, array( 'correct', 'incorrect' ), true ) ) { continue; } $attempt_answer = $answers_by_question_id[ $question_id ] ?? null; if ( ! $attempt_answer ) { $attempt_answer = $this->resolve_attempt_answer_for_review( $attempt_id, 0, $question_id ); } if ( ! $attempt_answer ) { continue; } $this->apply_quiz_answer_review( $attempt_id, $attempt_answer, $mark_as ); } QuizModel::update_attempt_result( $attempt_id ); $this->response_success( __( 'Review updated successfully', 'tutor' ) ); } /** * Get attempt answer record by ID. * * @since 4.0.0 * * @param int $attempt_answer_id Attempt answer ID. * * @return object|null */ public static function get_attempt_answer( int $attempt_answer_id ) { global $wpdb; return $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$wpdb->prefix}tutor_quiz_attempt_answers WHERE attempt_answer_id = %d", $attempt_answer_id ) ); } /** * Get attempt answer by attempt and question IDs. * * @since 4.0.0 * * @param int $attempt_id Attempt ID. * @param int $question_id Question ID. * * @return object|null */ private function get_attempt_answer_by_attempt_and_question( int $attempt_id, int $question_id ) { if ( $attempt_id <= 0 || $question_id <= 0 ) { return null; } return QueryHelper::get_row( 'tutor_quiz_attempt_answers', array( 'quiz_attempt_id' => $attempt_id, 'question_id' => $question_id, ), 'attempt_answer_id', 'ASC' ); } /** * Create a placeholder attempt answer for a skipped question. * * @since 4.0.0 * * @param int $attempt_id Attempt ID. * @param int $question_id Question ID. * * @return object|null */ private function create_skipped_attempt_answer( int $attempt_id, int $question_id ) { $attempt = tutor_utils()->get_attempt( $attempt_id ); if ( ! $attempt ) { return null; } $question = QuizModel::get_quiz_question_by_id( $question_id ); if ( ! $question || (int) $question->quiz_id !== (int) $attempt->quiz_id ) { return null; } $attempt_answer = $this->get_attempt_answer_by_attempt_and_question( $attempt_id, $question_id ); if ( $attempt_answer ) { return $attempt_answer; } try { $inserted_id = QueryHelper::insert( 'tutor_quiz_attempt_answers', array( 'user_id' => (int) $attempt->user_id, 'quiz_id' => (int) $attempt->quiz_id, 'question_id' => $question_id, 'quiz_attempt_id' => $attempt_id, 'given_answer' => '', 'question_mark' => $question->question_mark, 'achieved_mark' => 0, 'minus_mark' => 0, 'is_correct' => 0, ) ); } catch ( \Exception $exception ) { return null; } if ( $inserted_id <= 0 ) { return null; } return self::get_attempt_answer( $inserted_id ); } /** * Resolve the attempt answer used for instructor review. * * @since 4.0.0 * * @param int $attempt_id Attempt ID. * @param int $attempt_answer_id Attempt answer ID. * @param int $question_id Question ID. * * @return object|null */ private function resolve_attempt_answer_for_review( int $attempt_id, int $attempt_answer_id = 0, int $question_id = 0 ) { $attempt_answer = $attempt_answer_id ? self::get_attempt_answer( $attempt_answer_id ) : null; if ( $attempt_answer ) { return $attempt_answer; } if ( $question_id <= 0 ) { return null; } $attempt_answer = $this->get_attempt_answer_by_attempt_and_question( $attempt_id, $question_id ); if ( $attempt_answer ) { return $attempt_answer; } return $this->create_skipped_attempt_answer( $attempt_id, $question_id ); } /** * Apply quiz answer review update. * * @since 4.0.0 * * @param int $attempt_id Attempt ID. * @param object $attempt_answer Attempt answer row. * @param string $mark_as Review status. * * @return array|null */ private function apply_quiz_answer_review( int $attempt_id, $attempt_answer, string $mark_as ) { global $wpdb; if ( ! $attempt_answer || ! in_array( $mark_as, array( 'correct', 'incorrect' ), true ) ) { return null; } $attempt = tutor_utils()->get_attempt( $attempt_id ); if ( ! $attempt ) { return null; } $attempt_answer_id = (int) $attempt_answer->attempt_answer_id; $question = QuizModel::get_quiz_question_by_id( $attempt_answer->question_id ); $course_id = (int) $attempt->course_id; $student_id = (int) $attempt->user_id; $previous_ans = $attempt_answer->is_correct; do_action( 'tutor_quiz_review_answer_before', $attempt_answer_id, $attempt_id, $mark_as ); $mark_as = apply_filters( 'tutor_quiz_review_mark_as', $mark_as, $attempt_answer_id, $attempt_id, $question ); if ( 'correct' === $mark_as ) { $attempt_update_data = array(); $answer_update_data = array( 'achieved_mark' => $attempt_answer->question_mark, 'is_correct' => 1, ); $wpdb->update( $wpdb->prefix . 'tutor_quiz_attempt_answers', $answer_update_data, array( 'attempt_answer_id' => $attempt_answer_id ) ); if ( 0 == $previous_ans || null == $previous_ans ) { $attempt_update_data = array( 'earned_marks' => $attempt->earned_marks + $attempt_answer->question_mark, 'is_manually_reviewed' => 1, 'manually_reviewed_at' => date( 'Y-m-d H:i:s', tutor_time() ), //phpcs:ignore ); } if ( 'open_ended' === $question->question_type || 'short_answer' === $question->question_type ) { $attempt_update_data['attempt_status'] = QuizModel::ATTEMPT_ENDED; } if ( ! empty( $attempt_update_data ) ) { $wpdb->update( $wpdb->tutor_quiz_attempts, $attempt_update_data, array( 'attempt_id' => $attempt_id ) ); } } elseif ( 'incorrect' === $mark_as ) { $attempt_update_data = array(); $answer_update_data = array( 'achieved_mark' => '0.00', 'is_correct' => 0, ); $wpdb->update( $wpdb->prefix . 'tutor_quiz_attempt_answers', $answer_update_data, array( 'attempt_answer_id' => $attempt_answer_id ) ); if ( 1 == $previous_ans ) { $attempt_update_data = array( 'earned_marks' => $attempt->earned_marks - $attempt_answer->question_mark, 'is_manually_reviewed' => 1, 'manually_reviewed_at' => date( 'Y-m-d H:i:s', tutor_time() ), //phpcs:ignore ); } if ( 'open_ended' === $question->question_type || 'short_answer' === $question->question_type ) { $attempt_update_data['attempt_status'] = QuizModel::ATTEMPT_ENDED; } if ( ! empty( $attempt_update_data ) ) { $wpdb->update( $wpdb->tutor_quiz_attempts, $attempt_update_data, array( 'attempt_id' => $attempt_id ) ); } } do_action( 'tutor_quiz_review_answer_after', $attempt_answer_id, $attempt_id, $mark_as ); do_action( 'tutor_quiz/answer/review/after', $attempt_answer_id, $course_id, $student_id ); return array( 'course_id' => $course_id, 'student_id' => $student_id, ); } /** * Do auto course complete after review a quiz attempt. * * @since 2.4.0 * * @param int $attempt_answer_id attempt answer id. * @param int $course_id course id. * @param int $user_id student id. * * @return void */ public function do_auto_course_complete( $attempt_answer_id, $course_id, $user_id ) { if ( CourseModel::can_autocomplete_course( $course_id, $user_id ) ) { CourseModel::mark_course_as_completed( $course_id, $user_id ); Course::set_review_popup_data( $user_id, $course_id ); } } /** * Get a quiz details by id * * @return void */ public function ajax_quiz_details() { tutor_utils()->check_nonce(); $quiz_id = Input::post( 'quiz_id', 0, Input::TYPE_INT ); if ( ! tutor_utils()->can_user_manage( 'quiz', $quiz_id ) ) { $this->json_response( tutor_utils()->error_message(), null, HttpHelper::STATUS_FORBIDDEN ); } $data = QuizModel::get_quiz_details( $quiz_id ); $data = apply_filters( 'tutor_quiz_details_response', $data, $quiz_id ); $this->json_response( __( 'Quiz data fetched successfully', 'tutor' ), $data ); } /** * Delete quiz by id * * @since 1.0.0 * @since 3.0.0 refactor and response change. * * @return void */ public function ajax_quiz_delete() { if ( ! tutor_utils()->is_nonce_verified() ) { $this->json_response( tutor_utils()->error_message( 'nonce' ), null, HttpHelper::STATUS_BAD_REQUEST ); } global $wpdb; $quiz_id = Input::post( 'quiz_id', 0, Input::TYPE_INT ); if ( ! tutor_utils()->can_user_manage( 'quiz', $quiz_id ) ) { $this->json_response( tutor_utils()->error_message(), null, HttpHelper::STATUS_FORBIDDEN ); } $post = get_post( $quiz_id ); if ( 'tutor_quiz' !== $post->post_type ) { $this->json_response( __( 'Invalid quiz', 'tutor' ), null, HttpHelper::STATUS_BAD_REQUEST ); } do_action( 'tutor_delete_quiz_before', $quiz_id ); // Collect file paths from all question types that store files before deleting rows (files deleted after DB for safety). $attempts_for_quiz = QueryHelper::get_all( 'tutor_quiz_attempts', array( 'quiz_id' => $quiz_id ), 'attempt_id', -1 ); $attempt_file_paths = array(); if ( ! empty( $attempts_for_quiz ) ) { $attempt_ids = array_map( function ( $row ) { return (int) $row->attempt_id; }, $attempts_for_quiz ); $attempt_file_paths = apply_filters( 'tutor_quiz/attempt_file_paths_for_deletion', array(), $attempt_ids ); $attempt_file_paths = is_array( $attempt_file_paths ) ? array_values( array_filter( array_unique( $attempt_file_paths ) ) ) : array(); } $wpdb->delete( $wpdb->prefix . 'tutor_quiz_attempts', array( 'quiz_id' => $quiz_id ) ); $wpdb->delete( $wpdb->prefix . 'tutor_quiz_attempt_answers', array( 'quiz_id' => $quiz_id ) ); QuizModel::delete_files_by_paths( $attempt_file_paths ); // Collect instructor file paths before deleting question data (e.g. draw_image / pin_image masks). /** * Filter to get file paths for quiz deletion. * Pro and other add-ons register their question types via this filter. * * @param string[] $file_paths Paths collected so far. * @param int $quiz_id Quiz post ID. */ $quiz_file_paths = apply_filters( 'tutor_quiz_quiz_file_paths_for_deletion', array(), $quiz_id ); $questions_ids = $wpdb->get_col( $wpdb->prepare( "SELECT question_id FROM {$wpdb->prefix}tutor_quiz_questions WHERE quiz_id = %d ", $quiz_id ) ); if ( is_array( $questions_ids ) && count( $questions_ids ) ) { $in_question_ids = QueryHelper::prepare_in_clause( $questions_ids ); //phpcs:disable $wpdb->query( "DELETE FROM {$wpdb->prefix}tutor_quiz_question_answers WHERE belongs_question_id IN({$in_question_ids}) " ); //phpcs:enable } $wpdb->delete( $wpdb->prefix . 'tutor_quiz_questions', array( 'quiz_id' => $quiz_id ) ); QuizModel::delete_files_by_paths( $quiz_file_paths ); wp_delete_post( $quiz_id, true ); do_action( 'tutor_delete_quiz_after', $quiz_id ); $this->json_response( __( 'Quiz deleted successfully', 'tutor' ), $quiz_id ); } /** * Get answers by quiz id * * @since 1.0.0 * * @param int $question_id question id. * @param mixed $question_type type of question. * @param boolean $is_correct only correct answers or not. * * @return wpdb:get_results */ private function get_answers_by_q_id( $question_id, $question_type, $is_correct = false ) { global $wpdb; $correct_clause = $is_correct ? ' AND is_correct=1 ' : ''; //phpcs:disable return $wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$wpdb->prefix}tutor_quiz_question_answers WHERE belongs_question_id = %d AND belongs_question_type = %s {$correct_clause} ORDER BY answer_order ASC; ", $question_id, esc_sql( $question_type ) ) ); //phpcs:enable } /** * Rendering quiz for frontend * * @since 1.0.0 * * @return void send wp_json response */ public function tutor_render_quiz_content() { tutor_utils()->checking_nonce(); $quiz_id = Input::post( 'quiz_id', 0, Input::TYPE_INT ); if ( ! tutor_utils()->has_enrolled_content_access( 'quiz', $quiz_id ) ) { wp_send_json_error( array( 'message' => __( 'Access Denied.', 'tutor' ) ) ); } ob_start(); global $post; $post = get_post( $quiz_id ); //phpcs:ignore setup_postdata( $post ); single_quiz_contents(); wp_reset_postdata(); $html = ob_get_clean(); wp_send_json_success( array( 'html' => $html ) ); } /** * Get attempt details * * @since 1.0.0 * * @param int $attempt_id required attempt id to get details. * * @return mixed object on success, null on failure */ public static function attempt_details( int $attempt_id ) { global $wpdb; $attempt_details = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$wpdb->prefix}tutor_quiz_attempts WHERE attempt_id = %d ", $attempt_id ) ); return $attempt_details; } /** * Update quiz attempt info * * @since 1.0.0 * * @param int $attempt_id attempt id. * @param mixed $attempt_info serialize data. * * @return bool, true on success, false on failure */ public static function update_attempt_info( int $attempt_id, $attempt_info ) { global $wpdb; $table = $wpdb->prefix . 'tutor_quiz_attempts'; $update_info = $wpdb->update( $table, array( 'attempt_info' => $attempt_info ), array( 'attempt_id' => $attempt_id ) ); return $update_info ? true : false; } /** * Attempt delete ajax request handler * * @since 2.1.0 * * @return void wp_json response */ public function attempt_delete() { tutor_utils()->checking_nonce(); $attempt_id = Input::post( 'id', 0, Input::TYPE_INT ); $attempt = tutor_utils()->get_attempt( $attempt_id ); if ( ! $attempt ) { wp_send_json_error( __( 'Invalid attempt ID', 'tutor' ) ); } $user_id = get_current_user_id(); $course_id = $attempt->course_id; if ( tutor_utils()->can_user_edit_course( $user_id, $course_id ) ) { QuizModel::delete_quiz_attempt( $attempt_id ); wp_send_json_success( __( 'Attempt deleted successfully!', 'tutor' ) ); } else { wp_send_json_error( tutor_utils()->error_message() ); } } /** * Get all quiz attempts for a user in a specific course. * * @since 3.8.1 * * @param int $course_id The ID of the course. * * @return array Returns an array of quiz attempt objects with their answers, or an empty array on error. */ public function get_quiz_attempts_and_answers_by_course_id( int $course_id ): array { global $wpdb; $results = QueryHelper::get_all( $wpdb->tutor_quiz_attempts, array( 'course_id' => $course_id ), 'course_id', -1 ); if ( empty( $results ) ) { return array(); } return array_map( function ( $item ) { $item->quiz_attempt_answers = $this->get_quiz_attempt_answers_by_attempt_id( $item->attempt_id ); return $item; }, $results ); } /** * Get all quiz attempt answers for a specific quiz attempt. * * @since 3.8.1 * * @param int $attempt_id The ID of the quiz attempt. * * @return array Returns an array of quiz attempt answers objects, or an empty array on error. */ private function get_quiz_attempt_answers_by_attempt_id( int $attempt_id ): array { global $wpdb; $results = QueryHelper::get_all( $wpdb->tutor_quiz_attempt_answers, array( 'quiz_attempt_id' => $attempt_id ), 'quiz_attempt_id', -1 ); if ( empty( $results ) ) { return array(); } return $results; } /** * Render quiz title as nav item to show on the learning area * * @since 4.0.0 * * @param WP_Post $quiz Quiz post object. * @param bool $can_access Can user access this content. * * @return void */ public function render_nav_item( WP_Post $quiz, bool $can_access ): void { tutor_load_template( 'learning-area.quiz.nav-item', array( 'quiz' => $quiz, 'can_access' => $can_access, ) ); } /** * Render content for the a single quiz * * @since 4.0.0 * * @param WP_Post $quiz Quiz post object. * * @return void */ public function render_single_content( WP_Post $quiz ): void { tutor_load_template( 'learning-area.quiz.content', array( 'quiz' => $quiz, ) ); } /** * Render quiz summary * * @since 4.0.0 * * @param int $total_questions Total questions. * @param string $quiz_item_readable Readable time. * @param int $total_marks Total Marks. * @param string $passing_grade Passing grade. * @param string $earned_marks Earned marks. * @param string $attempts_allowed Total Attempts allowed. * * @return void */ public static function render_quiz_summary( $total_questions, $quiz_item_readable, $total_marks, $passing_grade, $earned_marks, $attempts_allowed ) { $quiz_summary = array( array( 'columns' => array( array( 'content' => '<div class="tutor-flex tutor-gap-3 tutor-items-center"> ' . SvgIcon::make()->name( Icon::QUESTION_CIRCLE )->size( 20 )->get() . __( 'Questions', 'tutor' ) . ' </div>', ), array( 'content' => $total_questions ), ), ), ); if ( ! empty( $quiz_item_readable ) ) { $quiz_summary[] = array( 'columns' => array( array( 'content' => '<div class="tutor-flex tutor-gap-3 tutor-items-center"> ' . SvgIcon::make()->name( Icon::TIME )->size( 20 )->get() . __( 'Quiz Time', 'tutor' ) . ' </div>', ), array( 'content' => $quiz_item_readable ), ), ); } $quiz_summary[] = array( 'columns' => array( array( 'content' => '<div class="tutor-flex tutor-gap-3 tutor-items-center"> ' . SvgIcon::make()->name( Icon::PRIME_CHECK_CIRCLE )->size( 20 )->get() . __( 'Total Marks', 'tutor' ) . ' </div>', ), array( 'content' => $total_marks ), ), ); $quiz_summary[] = array( 'columns' => array( array( 'content' => '<div class="tutor-flex tutor-gap-3 tutor-items-center"> ' . SvgIcon::make()->name( Icon::PASSED )->size( 20 )->get() . __( 'Passing Grade', 'tutor' ) . ' </div>', ), array( 'content' => $passing_grade . '%' ), ), ); if ( $earned_marks ) { $quiz_summary[] = array( 'columns' => array( array( 'content' => '<div class="tutor-flex tutor-gap-3 tutor-items-center"> ' . SvgIcon::make()->name( Icon::STAR )->size( 20 )->get() . __( 'Earned Grade', 'tutor' ) . ' </div>', ), array( 'content' => $earned_marks . '%' ), ), ); } if ( 1 !== $attempts_allowed ) { $quiz_summary[] = array( 'columns' => array( array( 'content' => '<div class="tutor-flex tutor-gap-3 tutor-items-center"> ' . SvgIcon::make()->name( Icon::TARGET )->size( 20 )->get() . __( 'Total Attempts', 'tutor' ) . ' </div>', ), array( 'content' => 0 === $attempts_allowed ? __( 'No Limit', 'tutor' ) : $attempts_allowed ), ), ); } Table::make()->contents( $quiz_summary )->render(); } /** * Render quiz attempts * * @since 4.0.0 * * @param int $quiz_id Quiz ID. * * @return void */ public static function render_quiz_attempts( $quiz_id ) { $quiz_id = tutor_utils()->get_post_id( $quiz_id ); if ( ! $quiz_id ) { return; } $user_id = get_current_user_id(); $quiz_model = new QuizModel(); $attempts = $quiz_model->quiz_attempts( $quiz_id, $user_id ); if ( empty( $attempts ) ) { return; } $attempts_list = QuizModel::format_quiz_attempts( $attempts, '' ); if ( empty( $attempts_list ) ) { return; } $attempts_count = count( $attempts_list ); $quiz_attempt_obj = new Quiz_Attempts_List( false ); ?> <div class="tutor-quiz-attempts tutor-border tutor-rounded-2xl"> <div class="tutor-quiz-attempts-header"> <div class="tutor-quiz-attempts-header-item"> <?php esc_html_e( 'Attempts', 'tutor' ); ?> </div> <div class="tutor-quiz-attempts-header-item"> <?php esc_html_e( 'Marks', 'tutor' ); ?> </div> <div class="tutor-quiz-attempts-header-item"> <?php esc_html_e( 'Time', 'tutor' ); ?> </div> <div class="tutor-quiz-attempts-header-item"> <?php esc_html_e( 'Result', 'tutor' ); ?> </div> </div> <div class="tutor-quiz-attempts-list"> <?php foreach ( $attempts_list as $index => $attempt ) { $attempt_number = $attempts_count - $index; ?> <div class="tutor-quiz-attempts-item-wrapper"> <?php tutor_load_template( 'shared.components.student-quiz-attempt-row', array( 'attempt' => $attempt, 'attempt_number' => $attempt_number, 'quiz_id' => $attempt['quiz_id'] ?? 0, 'course_id' => $attempt['course_id'] ?? 0, 'quiz_attempt_obj' => $quiz_attempt_obj, 'is_previous' => false, 'is_learning_area' => true, ) ); ?> <div class="tutor-quiz-item-actions"> <?php $quiz_attempt_obj->render_details_button( $attempt, true ); ?> </div> </div> <?php } ?> </div> </div> <?php } /** * Render quiz actions * * @since 4.0.0 * * @param int $quiz_id Quiz ID. * * @return void */ public static function render_quiz_actions( $quiz_id ) { $quiz_id = tutor_utils()->get_post_id( $quiz_id ); if ( ! $quiz_id ) { return; } $quiz_settings = tutor_utils()->get_quiz_option( $quiz_id ); $user_id = get_current_user_id(); $quiz_model = new QuizModel(); $attempts = $quiz_model->quiz_attempts( $quiz_id, $user_id ); $attempted_count = (int) tutor_utils()->count( $attempts ); $limit_attempts_allowed = '1' === (string) ( $quiz_settings['limit_attempts_allowed'] ?? '0' ); $attempts_allowed = (int) ( $quiz_settings['attempts_allowed'] ?? 0 ); $can_start_quiz = 0 === $attempted_count || self::can_retry_quiz( $limit_attempts_allowed, $attempts_allowed, $attempted_count ); $quiz_auto_start = $quiz_settings['quiz_auto_start'] ?? 0; $auto_start_delay = (int) ( $quiz_settings['auto_start_delay'] ?? 5 ); $should_auto_start = 1 === (int) $quiz_auto_start && 0 === (int) $attempted_count; global $tutor_current_post, $tutor_course_id; $current_content_id = $tutor_current_post ? $tutor_current_post->ID : $quiz_id; $course_id = $tutor_course_id ? $tutor_course_id : tutor_utils()->get_course_id_by_subcontent( $current_content_id ); $contents = tutor_utils()->get_course_prev_next_contents_by_id( $current_content_id ); $next_id = $contents ? $contents->next_id : 0; $skip_url = get_the_permalink( $next_id ? $next_id : $course_id ); $skip_modal_id = 'tutor-quiz-skip-to-next'; $auto_start_modal_id = 'tutor-quiz-autostart-modal'; $retry_modal_id = 'tutor-quiz-retry-modal-' . $quiz_id; $show_retry_modal = $attempted_count > 0; $can_skip_quiz = ( 0 === $attempted_count ); $show_continue = ( $attempted_count > 0 && $next_id ); $has_any_action = $can_skip_quiz || $show_continue || $can_start_quiz; if ( ! $has_any_action ) { return; } ?> <div class="tutor-learning-area-footer"> <?php if ( $can_skip_quiz ) { Button::make() ->label( __( 'Skip Quiz', 'tutor' ) ) ->variant( Variant::GHOST ) ->attr( '@click', "TutorCore.modal.showModal('$skip_modal_id')" ) ->render(); $skip_modal_confirm_button = Button::make() ->tag( 'a' ) ->label( __( 'Yes, Skip This', 'tutor' ) ) ->variant( Variant::DESTRUCTIVE ) ->size( Size::SMALL ) ->attr( 'href', esc_url( $skip_url ) ) ->get(); $skip_modal_cancel_button = Button::make() ->label( __( 'Cancel', 'tutor' ) ) ->variant( Variant::SECONDARY ) ->size( Size::SMALL ) ->attr( '@click', "TutorCore.modal.closeModal('$skip_modal_id')" ) ->get(); ConfirmationModal::make() ->id( $skip_modal_id ) ->icon( tutor_utils()->get_themed_svg( 'images/illustrations/warning.svg' ), 80, 80, ConfirmationModal::ICON_TYPE_HTML ) ->title( __( 'Do You Want to Skip This Quiz?', 'tutor' ) ) ->message( __( 'Are you sure you want to skip this quiz? Please confirm your choice.', 'tutor' ) ) ->confirm_button( $skip_modal_confirm_button ) ->cancel_button( $skip_modal_cancel_button ) ->render(); } ?> <?php if ( $can_start_quiz ) : ?> <?php if ( $show_retry_modal ) : ?> <?php Button::make() ->label( __( 'Retry Quiz', 'tutor' ) ) ->variant( $show_continue ? Variant::GHOST : Variant::PRIMARY ) ->attr( 'type', 'button' ) ->attr( '@click', sprintf( 'TutorCore.modal.showModal("%s", { data: %s });', $retry_modal_id, wp_json_encode( array( 'quizID' => $quiz_id, 'redirectURL' => get_post_permalink( $quiz_id ), ) ) ) ) ->render(); ?> <?php else : ?> <form x-data="tutorQuizAutoStart({ quizID: <?php echo esc_attr( $quiz_id ); ?>, autoStart: <?php echo $should_auto_start ? 'true' : 'false'; ?>, autoStartModalId: '<?php echo esc_attr( $auto_start_modal_id ); ?>', countdownSeconds: <?php echo esc_attr( $auto_start_delay ); ?>, })" @submit.prevent="handleStartQuiz()" > <?php Button::make() ->label( __( 'Start Quiz', 'tutor' ) ) ->attr( 'x-bind:disabled', 'startQuizMutation?.isPending' ) ->attr( ':class', "{ 'tutor-btn-loading': startQuizMutation?.isPending }" ) ->render(); ?> </form> <?php endif; ?> <?php endif; ?> <?php if ( $show_continue ) { Button::make() ->tag( 'a' ) ->label( __( 'Continue Lesson', 'tutor' ) ) ->attr( 'href', esc_url( get_the_permalink( $next_id ) ) ) ->render(); } ?> </div> <?php if ( $show_retry_modal ) : ?> <div x-data="tutorQuizRetryAttempt()"> <?php ConfirmationModal::make() ->id( $retry_modal_id ) ->title( __( 'Retry Quiz?', 'tutor' ) ) ->icon( tutor_utils()->get_themed_svg( 'images/illustrations/quiz-retry.svg' ), 80, 80, ConfirmationModal::ICON_TYPE_HTML ) ->message( __( 'Retrying this quiz will reset your current attempt. Your answers and score from this attempt will be lost.', 'tutor' ) ) ->confirm_handler( 'retryMutation?.mutate({...payload?.data})' ) ->confirm_text( __( 'Retry Quiz', 'tutor' ) ) ->mutation_state( 'retryMutation' ) ->render(); ?> </div> <?php endif; ?> <?php Modal::make() ->id( $auto_start_modal_id ) ->closeable( false ) ->width( '268px' ) ->template( tutor()->path . 'templates/learning-area/quiz/modals/auto-start.php', array( 'countdown_seconds' => $auto_start_delay, 'modal_id' => $auto_start_modal_id, ) ) ->render(); ?> <?php } /** * Render individual question template * * @since 4.0.0 * * @param object $question Question data. * @param int $index Question index. * * @return void */ public static function render_question( $question, $index = 0 ) { $question_settings = maybe_unserialize( $question->question_settings ); $question_type = $question->question_type; $rand_choice = ! empty( $question_settings['randomize_question'] ) && '1' === $question_settings['randomize_question']; // Normalize question type + settings. switch ( $question->question_type ) { case 'short_answer': $question->question_type = 'open_ended'; break; case 'single_choice': $question->question_type = 'multiple_choice'; $question_settings['has_multiple_correct_answer'] = '0'; break; case 'image_matching': $question->question_type = 'matching'; $question_settings['is_image_matching'] = '1'; break; } $template = str_replace( '_', '-', $question->question_type ); $answers = self::prepare_question_answers( (int) $question->question_id, $question_type, $rand_choice ); $question->index = $index; $question->question_settings = $question_settings; $question->question_answers = $answers['question_answers']; $question->question_randomized_answers = $answers['question_randomized_answers']; tutor_load_template( 'learning-area.quiz.question', array( 'question' => $question, 'question_settings' => $question_settings, 'question_type' => $template, ) ); } /** * Get question answers prepared for render. * * @since 4.0.0 * * @param int $question_id Question ID. * @param string $question_type Original question type. * @param bool $rand_choice Whether randomized choices are enabled. * * @return array{question_answers: array, question_randomized_answers: array} */ private static function prepare_question_answers( int $question_id, string $question_type, bool $rand_choice ): array { $question_answers = QuizModel::get_answers_by_quiz_question( $question_id ); $question_randomized_answers = array(); // Ordering questions always use a randomized answer list. // Matching and image matching keep the drop-zone items ordered and only shuffle the draggable choices. if ( 'ordering' === $question_type ) { $question_answers = QuizModel::get_answers_by_quiz_question( $question_id, true ); } elseif ( 'matching' === $question_type || 'image_matching' === $question_type ) { $question_randomized_answers = QuizModel::get_answers_by_quiz_question( $question_id, $rand_choice ); } elseif ( $rand_choice ) { $question_answers = QuizModel::get_answers_by_quiz_question( $question_id, true ); } $question_answers = array_map( static fn( $answer ) => (array) $answer, $question_answers ); $question_randomized_answers = array_map( static fn( $answer ) => (array) $answer, $question_randomized_answers ); return array( 'question_answers' => $question_answers, 'question_randomized_answers' => $question_randomized_answers, ); } /** * Renders the assignment status icon for the learning area navigation. * * @since 4.0.0 * * @param WP_Post $quiz The assignment post object. * @param bool $can_access Whether the current user can access the assignment. * @param int $tutor_current_content_id Current content id. * * @return void */ public static function render_sidebar_nav( WP_Post $quiz, $can_access, $tutor_current_content_id ) { $quiz_status = ''; $icon_name = Icon::QUIZ_2; if ( ! $can_access ) { $icon_name = Icon::LOCK_STROKE_2; } else { $last_attempt = ( new QuizModel() )->get_first_or_last_attempt( $quiz->ID ); $attempt_ended = is_object( $last_attempt ) && QuizModel::ATTEMPT_STARTED !== $last_attempt->attempt_status; $quiz_result = QuizModel::get_quiz_result( $quiz->ID ); if ( $attempt_ended && QuizModel::ATTEMPT_STARTED !== $last_attempt->attempt_status ) { if ( QuizModel::RESULT_FAIL === $quiz_result ) { $icon_name = Icon::CROSS_COLORIZE; $quiz_status = QuizModel::RESULT_FAIL; } elseif ( QuizModel::RESULT_PENDING === $quiz_result ) { $icon_name = Icon::INFO_COLORIZE; $quiz_status = QuizModel::RESULT_PENDING; } elseif ( QuizModel::RESULT_PASS === $quiz_result ) { $icon_name = Icon::COMPLETED_COLORIZE; } } } tutor_load_template( 'learning-area.components.sidebar-nav-item', array( 'item' => $quiz, 'active' => $tutor_current_content_id === $quiz->ID, 'can_access' => $can_access, 'is_completed' => false, 'type_label' => __( 'Quiz', 'tutor' ), 'icon' => $icon_name, 'status_class' => $quiz_status, ) ); } }
修改文件时间
将文件时间修改为当前时间的前一年
删除文件