From 446cc29744863c04b01487d5f2cd769a7e3f087f Mon Sep 17 00:00:00 2001 From: b-l-i-n-d Date: Thu, 27 Aug 2026 16:20:43 +0600 Subject: [PATCH 1/6] fix(quiz): calculate total marks properly for random question order --- classes/Quiz.php | 76 +++++++++++++++++++----- templates/learning-area/quiz/content.php | 13 ++-- 2 files changed, 69 insertions(+), 20 deletions(-) diff --git a/classes/Quiz.php b/classes/Quiz.php index 7e063734ed..445420251b 100644 --- a/classes/Quiz.php +++ b/classes/Quiz.php @@ -1050,7 +1050,7 @@ public function finishing_quiz_attempt() { } /** - * Get quiz total marks. + * Get total marks for a quiz * * @since 3.0.0 * @@ -1061,16 +1061,62 @@ public function finishing_quiz_attempt() { public static function get_quiz_total_marks( $quiz_id ) { global $wpdb; - $total_marks = $wpdb->get_var( + if ( ! $quiz_id ) { + return 0; + } + + $questions = $wpdb->get_results( $wpdb->prepare( - "SELECT SUM(question_mark) total_marks + "SELECT question_id, question_mark, question_order FROM {$wpdb->prefix}tutor_quiz_questions - WHERE quiz_id=%d", + WHERE quiz_id = %d", $quiz_id ) ); - return floatval( $total_marks ); + if ( empty( $questions ) ) { + return 0; + } + + $total_count = count( $questions ); + $max_count = (int) tutor_utils()->total_questions_for_student_by_quiz( $quiz_id ); + $total_marks = 0; + + // If no limit or max_count exceeds pool size, return sum of all. + if ( $max_count <= 0 || $max_count >= $total_count ) { + $total_marks = array_sum( array_column( $questions, 'question_mark' ) ); + } else { + $questions_order = tutor_utils()->get_quiz_option( $quiz_id, 'questions_order', 'rand' ); + + // Deterministic order: sum marks of the first $max_count questions. + if ( 'sorting' === $questions_order ) { + usort( $questions, fn( $a, $b ) => (int) $a->question_order <=> (int) $b->question_order ); + $sliced = array_slice( $questions, 0, $max_count ); + $total_marks = array_sum( array_column( $sliced, 'question_mark' ) ); + } elseif ( 'asc' === $questions_order ) { + usort( $questions, fn( $a, $b ) => (int) $a->question_id <=> (int) $b->question_id ); + $sliced = array_slice( $questions, 0, $max_count ); + $total_marks = array_sum( array_column( $sliced, 'question_mark' ) ); + } elseif ( 'desc' === $questions_order ) { + usort( $questions, fn( $a, $b ) => (int) $b->question_id <=> (int) $a->question_id ); + $sliced = array_slice( $questions, 0, $max_count ); + $total_marks = array_sum( array_column( $sliced, 'question_mark' ) ); + } else { + // Random order: + // If question marks vary, total marks is indeterminate before starting. + $marks = array_map( 'floatval', array_column( $questions, 'question_mark' ) ); + $unique_marks = array_unique( $marks ); + + if ( count( $unique_marks ) > 1 ) { + return 0; + } + + // All questions have equal marks: exact calculation. + $total_marks = reset( $unique_marks ) * $max_count; + } + } + + return (float) $total_marks; } /** @@ -1848,16 +1894,18 @@ public static function render_quiz_summary( $total_questions, $quiz_item_readabl ); } - $quiz_summary[] = array( - 'columns' => array( - array( - 'content' => '
- ' . SvgIcon::make()->name( Icon::PRIME_CHECK_CIRCLE )->size( 20 )->get() . __( 'Total Marks', 'tutor' ) . ' -
', + if ( ! empty( $total_marks ) ) { + $quiz_summary[] = array( + 'columns' => array( + array( + 'content' => '
+ ' . SvgIcon::make()->name( Icon::PRIME_CHECK_CIRCLE )->size( 20 )->get() . __( 'Total Marks', 'tutor' ) . ' +
', + ), + array( 'content' => (float) $total_marks ), ), - array( 'content' => $total_marks ), - ), - ); + ); + } $quiz_summary[] = array( 'columns' => array( diff --git a/templates/learning-area/quiz/content.php b/templates/learning-area/quiz/content.php index a716e2f3ae..8179fd1654 100644 --- a/templates/learning-area/quiz/content.php +++ b/templates/learning-area/quiz/content.php @@ -13,7 +13,7 @@ use Tutor\Helpers\UrlHelper; use TUTOR\Quiz; -use TUTOR\Models\QuizModel; +use Tutor\Models\QuizModel; global $tutor_current_post, $tutor_course_id; @@ -25,7 +25,6 @@ $quiz_id = $quiz->ID; $total_questions = (int) tutor_utils()->total_questions_for_student_by_quiz( $quiz_id ); $quiz_options = tutor_utils()->get_quiz_option( $quiz_id ); -$total_marks = Quiz::get_quiz_total_marks( $quiz_id ); $passing_grade = (int) ( $quiz_options['passing_grade'] ?? 0 ); $quiz_time = $quiz_options['time_limit'] ?? null; $has_time_limit = is_array( $quiz_time ) && ! empty( $quiz_time['time_value'] ) && (int) $quiz_time['time_value'] > 0; @@ -33,11 +32,13 @@ $quiz_item_readable = $has_time_limit ? $quiz_time['time_value'] . ' ' . $time_units[ $quiz_time['time_type'] ] : null; $quiz_attempt = ( new QuizModel() )->get_quiz_attempt( $quiz_id, $user_id ?? get_current_user_id() ); $earned_marks = 0; +$total_marks = 0; -if ( $quiz_attempt && $total_marks > 0 ) { - $earned_marks = (float) $quiz_attempt->earned_marks; - $total = (float) $total_marks; - $earned_marks = number_format( ( $earned_marks / $total_marks ) * 100, 2 ); +if ( is_object( $quiz_attempt ) && (float) ( $quiz_attempt->total_marks ?? 0 ) > 0 ) { + $total_marks = (float) $quiz_attempt->total_marks; + $earned_marks = QuizModel::calculate_attempt_earned_percentage( $quiz_attempt ); +} else { + $total_marks = Quiz::get_quiz_total_marks( $quiz_id ); } $limit_attempts = (int) $quiz_options['limit_attempts_allowed'] ?? 0; $allowed_attempts = $limit_attempts ? $quiz_options['attempts_allowed'] ?? '' : '1'; From ca818043572f4f18eeddd856f8878ad7f702e7a5 Mon Sep 17 00:00:00 2001 From: b-l-i-n-d Date: Mon, 31 Aug 2026 15:45:36 +0600 Subject: [PATCH 2/6] refactor(quiz): consolidate get_quiz_total_marks to a single SQL query --- classes/Quiz.php | 78 ++++++++++++++++++++---------------------------- 1 file changed, 32 insertions(+), 46 deletions(-) diff --git a/classes/Quiz.php b/classes/Quiz.php index 445420251b..3d16f7111d 100644 --- a/classes/Quiz.php +++ b/classes/Quiz.php @@ -1065,57 +1065,43 @@ public static function get_quiz_total_marks( $quiz_id ) { return 0; } - $questions = $wpdb->get_results( + $max_questions = (int) tutor_utils()->get_quiz_option( $quiz_id, 'max_questions_for_answer' ); + $questions_order = tutor_utils()->get_quiz_option( $quiz_id, 'questions_order', 'rand' ); + + $order_by_map = array( + 'asc' => 'question_id ASC', + 'desc' => 'question_id DESC', + 'sorting' => 'question_order ASC', + ); + $order_by = $order_by_map[ $questions_order ] ?? 'question_order ASC'; + + $limit = $max_questions > 0 ? $max_questions : PHP_INT_MAX; + + $total_marks = $wpdb->get_var( $wpdb->prepare( - "SELECT question_id, question_mark, question_order - FROM {$wpdb->prefix}tutor_quiz_questions - WHERE quiz_id = %d", + "SELECT CASE + WHEN %s = 'rand' AND %d > 0 AND COUNT(question_id) > %d THEN 0 + ELSE ( + SELECT SUM(questions.question_mark) FROM ( + SELECT question_mark + FROM {$wpdb->prefix}tutor_quiz_questions + WHERE quiz_id = %d + ORDER BY {$order_by} + LIMIT %d + ) AS questions + ) + END + FROM {$wpdb->prefix}tutor_quiz_questions + WHERE quiz_id = %d", + $questions_order, + $max_questions, + $max_questions, + $quiz_id, + $limit, $quiz_id ) ); - if ( empty( $questions ) ) { - return 0; - } - - $total_count = count( $questions ); - $max_count = (int) tutor_utils()->total_questions_for_student_by_quiz( $quiz_id ); - $total_marks = 0; - - // If no limit or max_count exceeds pool size, return sum of all. - if ( $max_count <= 0 || $max_count >= $total_count ) { - $total_marks = array_sum( array_column( $questions, 'question_mark' ) ); - } else { - $questions_order = tutor_utils()->get_quiz_option( $quiz_id, 'questions_order', 'rand' ); - - // Deterministic order: sum marks of the first $max_count questions. - if ( 'sorting' === $questions_order ) { - usort( $questions, fn( $a, $b ) => (int) $a->question_order <=> (int) $b->question_order ); - $sliced = array_slice( $questions, 0, $max_count ); - $total_marks = array_sum( array_column( $sliced, 'question_mark' ) ); - } elseif ( 'asc' === $questions_order ) { - usort( $questions, fn( $a, $b ) => (int) $a->question_id <=> (int) $b->question_id ); - $sliced = array_slice( $questions, 0, $max_count ); - $total_marks = array_sum( array_column( $sliced, 'question_mark' ) ); - } elseif ( 'desc' === $questions_order ) { - usort( $questions, fn( $a, $b ) => (int) $b->question_id <=> (int) $a->question_id ); - $sliced = array_slice( $questions, 0, $max_count ); - $total_marks = array_sum( array_column( $sliced, 'question_mark' ) ); - } else { - // Random order: - // If question marks vary, total marks is indeterminate before starting. - $marks = array_map( 'floatval', array_column( $questions, 'question_mark' ) ); - $unique_marks = array_unique( $marks ); - - if ( count( $unique_marks ) > 1 ) { - return 0; - } - - // All questions have equal marks: exact calculation. - $total_marks = reset( $unique_marks ) * $max_count; - } - } - return (float) $total_marks; } From 049c53d28e7144c4129c54fefaceaec0f0fa0ccd Mon Sep 17 00:00:00 2001 From: b-l-i-n-d Date: Mon, 31 Aug 2026 15:56:39 +0600 Subject: [PATCH 3/6] chore: Fix phpcs error --- classes/Quiz.php | 76 +++++++++++++++++++++++++++--------------------- 1 file changed, 43 insertions(+), 33 deletions(-) diff --git a/classes/Quiz.php b/classes/Quiz.php index 3d16f7111d..3665ad7e82 100644 --- a/classes/Quiz.php +++ b/classes/Quiz.php @@ -1068,39 +1068,49 @@ public static function get_quiz_total_marks( $quiz_id ) { $max_questions = (int) tutor_utils()->get_quiz_option( $quiz_id, 'max_questions_for_answer' ); $questions_order = tutor_utils()->get_quiz_option( $quiz_id, 'questions_order', 'rand' ); - $order_by_map = array( - 'asc' => 'question_id ASC', - 'desc' => 'question_id DESC', - 'sorting' => 'question_order ASC', - ); - $order_by = $order_by_map[ $questions_order ] ?? 'question_order ASC'; - - $limit = $max_questions > 0 ? $max_questions : PHP_INT_MAX; - - $total_marks = $wpdb->get_var( - $wpdb->prepare( - "SELECT CASE - WHEN %s = 'rand' AND %d > 0 AND COUNT(question_id) > %d THEN 0 - ELSE ( - SELECT SUM(questions.question_mark) FROM ( - SELECT question_mark - FROM {$wpdb->prefix}tutor_quiz_questions - WHERE quiz_id = %d - ORDER BY {$order_by} - LIMIT %d - ) AS questions - ) - END - FROM {$wpdb->prefix}tutor_quiz_questions - WHERE quiz_id = %d", - $questions_order, - $max_questions, - $max_questions, - $quiz_id, - $limit, - $quiz_id - ) - ); + // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + if ( $max_questions <= 0 ) { + // No question limit: sum all questions regardless of order. + $total_marks = $wpdb->get_var( + $wpdb->prepare( + "SELECT SUM(question_mark) + FROM {$wpdb->prefix}tutor_quiz_questions + WHERE quiz_id = %d", + $quiz_id + ) + ); + } else { + // Question limit is active. $order_by is resolved from a fixed allowlist; no user input reaches it. + $order_by_map = array( + 'asc' => 'question_id ASC', + 'desc' => 'question_id DESC', + 'sorting' => 'question_order ASC', + ); + $order_by = $order_by_map[ $questions_order ] ?? 'question_order ASC'; + + // • rand + COUNT > limit → a true random subset, total is unknowable → 0. + // • rand + COUNT ≤ limit → all questions shown, sum is deterministic. + // • deterministic → sum of the first $max_questions by ORDER BY. + // phpcs:disable WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $sql = "SELECT CASE + WHEN %s = 'rand' AND COUNT(question_id) > %d THEN 0 + ELSE ( + SELECT SUM(q.question_mark) FROM ( + SELECT question_mark + FROM {$wpdb->prefix}tutor_quiz_questions + WHERE quiz_id = %d + ORDER BY {$order_by} + LIMIT %d + ) AS q + ) + END + FROM {$wpdb->prefix}tutor_quiz_questions + WHERE quiz_id = %d"; + + $total_marks = $wpdb->get_var( $wpdb->prepare( $sql, $questions_order, $max_questions, $quiz_id, $max_questions, $quiz_id ) ); + // phpcs:enable WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared + } + // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching return (float) $total_marks; } From 2ab1fa7a204aef8ab40330662666b6f35471acfa Mon Sep 17 00:00:00 2001 From: b-l-i-n-d Date: Mon, 31 Aug 2026 16:05:50 +0600 Subject: [PATCH 4/6] chore: Resolve phpcs error --- classes/Quiz.php | 118 ++++++++++++++++++++++------------------------- 1 file changed, 55 insertions(+), 63 deletions(-) diff --git a/classes/Quiz.php b/classes/Quiz.php index 3665ad7e82..97241a13d9 100644 --- a/classes/Quiz.php +++ b/classes/Quiz.php @@ -1049,71 +1049,63 @@ public function finishing_quiz_attempt() { wp_redirect( tutor_utils()->input_old( '_wp_http_referer' ) ); } - /** - * Get total marks for a quiz - * - * @since 3.0.0 - * - * @param int $quiz_id quiz id. - * - * @return int|float - */ - public static function get_quiz_total_marks( $quiz_id ) { - global $wpdb; - - if ( ! $quiz_id ) { - return 0; - } - - $max_questions = (int) tutor_utils()->get_quiz_option( $quiz_id, 'max_questions_for_answer' ); - $questions_order = tutor_utils()->get_quiz_option( $quiz_id, 'questions_order', 'rand' ); - - // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching - if ( $max_questions <= 0 ) { - // No question limit: sum all questions regardless of order. - $total_marks = $wpdb->get_var( - $wpdb->prepare( - "SELECT SUM(question_mark) - FROM {$wpdb->prefix}tutor_quiz_questions - WHERE quiz_id = %d", - $quiz_id - ) - ); - } else { - // Question limit is active. $order_by is resolved from a fixed allowlist; no user input reaches it. - $order_by_map = array( - 'asc' => 'question_id ASC', - 'desc' => 'question_id DESC', - 'sorting' => 'question_order ASC', - ); - $order_by = $order_by_map[ $questions_order ] ?? 'question_order ASC'; - - // • rand + COUNT > limit → a true random subset, total is unknowable → 0. - // • rand + COUNT ≤ limit → all questions shown, sum is deterministic. - // • deterministic → sum of the first $max_questions by ORDER BY. - // phpcs:disable WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared - $sql = "SELECT CASE - WHEN %s = 'rand' AND COUNT(question_id) > %d THEN 0 - ELSE ( - SELECT SUM(q.question_mark) FROM ( - SELECT question_mark - FROM {$wpdb->prefix}tutor_quiz_questions - WHERE quiz_id = %d - ORDER BY {$order_by} - LIMIT %d - ) AS q - ) + /** + * Get total marks for a quiz + * + * @since 3.0.0 + * + * @param int $quiz_id quiz id. + * + * @return int|float + */ + public static function get_quiz_total_marks( $quiz_id ) { + global $wpdb; + + if ( ! $quiz_id ) { + return 0; + } + + $max_questions = (int) tutor_utils()->get_quiz_option( $quiz_id, 'max_questions_for_answer' ); + $questions_order = tutor_utils()->get_quiz_option( $quiz_id, 'questions_order', 'rand' ); + + $order_by_map = array( + 'asc' => 'question_id ASC', + 'desc' => 'question_id DESC', + 'sorting' => 'question_order ASC', + ); + $order_by = $order_by_map[ $questions_order ] ?? 'question_order ASC'; + + $limit = $max_questions > 0 ? $max_questions : PHP_INT_MAX; + + // phpcs:disable + $total_marks = $wpdb->get_var( + $wpdb->prepare( + "SELECT CASE + WHEN %s = 'rand' AND %d > 0 AND COUNT(question_id) > %d THEN 0 + ELSE ( + SELECT SUM(questions.question_mark) FROM ( + SELECT question_mark + FROM {$wpdb->prefix}tutor_quiz_questions + WHERE quiz_id = %d + ORDER BY {$order_by} + LIMIT %d + ) AS questions + ) END FROM {$wpdb->prefix}tutor_quiz_questions - WHERE quiz_id = %d"; - - $total_marks = $wpdb->get_var( $wpdb->prepare( $sql, $questions_order, $max_questions, $quiz_id, $max_questions, $quiz_id ) ); - // phpcs:enable WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared - } - // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching - - return (float) $total_marks; - } + WHERE quiz_id = %d", + $questions_order, + $max_questions, + $max_questions, + $quiz_id, + $limit, + $quiz_id + ) + ); + // phpcs:enable + + return (float) $total_marks; + } /** * Quiz timeout by ajax From aaec4497824d1d7a027c87895b3ef0ad4ce9182f Mon Sep 17 00:00:00 2001 From: b-l-i-n-d Date: Mon, 31 Aug 2026 16:09:02 +0600 Subject: [PATCH 5/6] chore: Use tab instead of space --- classes/Quiz.php | 88 ++++++++++++++++++++++++------------------------ 1 file changed, 44 insertions(+), 44 deletions(-) diff --git a/classes/Quiz.php b/classes/Quiz.php index 97241a13d9..a8b0392b05 100644 --- a/classes/Quiz.php +++ b/classes/Quiz.php @@ -1049,38 +1049,38 @@ public function finishing_quiz_attempt() { wp_redirect( tutor_utils()->input_old( '_wp_http_referer' ) ); } - /** - * Get total marks for a quiz - * - * @since 3.0.0 - * - * @param int $quiz_id quiz id. - * - * @return int|float - */ - public static function get_quiz_total_marks( $quiz_id ) { - global $wpdb; - - if ( ! $quiz_id ) { - return 0; - } - - $max_questions = (int) tutor_utils()->get_quiz_option( $quiz_id, 'max_questions_for_answer' ); - $questions_order = tutor_utils()->get_quiz_option( $quiz_id, 'questions_order', 'rand' ); - - $order_by_map = array( - 'asc' => 'question_id ASC', - 'desc' => 'question_id DESC', - 'sorting' => 'question_order ASC', - ); - $order_by = $order_by_map[ $questions_order ] ?? 'question_order ASC'; - - $limit = $max_questions > 0 ? $max_questions : PHP_INT_MAX; - - // phpcs:disable - $total_marks = $wpdb->get_var( - $wpdb->prepare( - "SELECT CASE + /** + * Get total marks for a quiz + * + * @since 3.0.0 + * + * @param int $quiz_id quiz id. + * + * @return int|float + */ + public static function get_quiz_total_marks( $quiz_id ) { + global $wpdb; + + if ( ! $quiz_id ) { + return 0; + } + + $max_questions = (int) tutor_utils()->get_quiz_option( $quiz_id, 'max_questions_for_answer' ); + $questions_order = tutor_utils()->get_quiz_option( $quiz_id, 'questions_order', 'rand' ); + + $order_by_map = array( + 'asc' => 'question_id ASC', + 'desc' => 'question_id DESC', + 'sorting' => 'question_order ASC', + ); + $order_by = $order_by_map[ $questions_order ] ?? 'question_order ASC'; + + $limit = $max_questions > 0 ? $max_questions : PHP_INT_MAX; + + // phpcs:disable + $total_marks = $wpdb->get_var( + $wpdb->prepare( + "SELECT CASE WHEN %s = 'rand' AND %d > 0 AND COUNT(question_id) > %d THEN 0 ELSE ( SELECT SUM(questions.question_mark) FROM ( @@ -1094,18 +1094,18 @@ public static function get_quiz_total_marks( $quiz_id ) { END FROM {$wpdb->prefix}tutor_quiz_questions WHERE quiz_id = %d", - $questions_order, - $max_questions, - $max_questions, - $quiz_id, - $limit, - $quiz_id - ) - ); - // phpcs:enable - - return (float) $total_marks; - } + $questions_order, + $max_questions, + $max_questions, + $quiz_id, + $limit, + $quiz_id + ) + ); + // phpcs:enable + + return (float) $total_marks; + } /** * Quiz timeout by ajax From 119fe6c1de352055fc470f3ca018fd01a6990933 Mon Sep 17 00:00:00 2001 From: b-l-i-n-d Date: Mon, 31 Aug 2026 16:10:52 +0600 Subject: [PATCH 6/6] chore: Use tab instead of space --- classes/Quiz.php | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/classes/Quiz.php b/classes/Quiz.php index a8b0392b05..8ce8e8d9d0 100644 --- a/classes/Quiz.php +++ b/classes/Quiz.php @@ -1081,16 +1081,16 @@ public static function get_quiz_total_marks( $quiz_id ) { $total_marks = $wpdb->get_var( $wpdb->prepare( "SELECT CASE - WHEN %s = 'rand' AND %d > 0 AND COUNT(question_id) > %d THEN 0 - ELSE ( - SELECT SUM(questions.question_mark) FROM ( - SELECT question_mark - FROM {$wpdb->prefix}tutor_quiz_questions - WHERE quiz_id = %d - ORDER BY {$order_by} - LIMIT %d - ) AS questions - ) + WHEN %s = 'rand' AND %d > 0 AND COUNT(question_id) > %d THEN 0 + ELSE ( + SELECT SUM(questions.question_mark) FROM ( + SELECT question_mark + FROM {$wpdb->prefix}tutor_quiz_questions + WHERE quiz_id = %d + ORDER BY {$order_by} + LIMIT %d + ) AS questions + ) END FROM {$wpdb->prefix}tutor_quiz_questions WHERE quiz_id = %d",