From 3041619d462612a9a54cb62ba3b38f34a7574ceb Mon Sep 17 00:00:00 2001 From: Sazedul Haque Date: Mon, 19 Oct 2026 15:48:54 +0600 Subject: [PATCH 1/4] Use quiz answer stats for earned marks Replace the direct SUM query with QuizModel::get_attempt_answers_stats() to retrieve an attempt's total earned marks. --- classes/Quiz.php | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/classes/Quiz.php b/classes/Quiz.php index 0c29ae0b4d..f7428995ef 100644 --- a/classes/Quiz.php +++ b/classes/Quiz.php @@ -2043,15 +2043,7 @@ private function apply_quiz_answer_review( int $attempt_id, $attempt_answer, str * @return float */ private function calculate_attempt_earned_marks( int $attempt_id ): float { - $marks = QueryHelper::query( - 'tutor_quiz_attempt_answers', - array( - 'select' => 'SUM(achieved_mark) AS earned_marks', - 'where' => array( 'quiz_attempt_id' => $attempt_id ), - ) - ); - - return max( 0.0, (float) ( $marks[0]->earned_marks ?? 0.0 ) ); + return QuizModel::get_attempt_answers_stats( $attempt_id )->total_earned_marks; } /** From 50a1584d643f060f0c7ac5ddf51d0a87b864a4a4 Mon Sep 17 00:00:00 2001 From: Sazedul Haque Date: Mon, 19 Oct 2026 15:54:49 +0600 Subject: [PATCH 2/4] Handle floats without decimal parts Cast float values to strings before splitting and default missing decimal digits to zero, preventing errors for whole-number floats and values without a decimal point. --- helpers/QueryHelper.php | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/helpers/QueryHelper.php b/helpers/QueryHelper.php index 8285b2f330..76ecd042d5 100644 --- a/helpers/QueryHelper.php +++ b/helpers/QueryHelper.php @@ -1049,9 +1049,9 @@ public static function prepare_value( $value ) { if ( is_int( $value ) ) { $escaped_value = $wpdb->prepare( '%d', $value ); } elseif ( is_float( $value ) ) { - list( $whole, $decimal ) = explode( '.', $value ); - $expression = '%.' . strlen( $decimal ) . 'f'; - $escaped_value = $wpdb->prepare( $expression, $value );//phpcs:ignore + $value_parts = explode( '.', (string) $value ); + $expression = '%.' . strlen( $value_parts[1] ?? '' ) . 'f'; + $escaped_value = $wpdb->prepare( $expression, $value );//phpcs:ignore } else { $escaped_value = $wpdb->prepare( '%s', $value ); } @@ -1509,5 +1509,4 @@ public static function get_table_schema( $table_name ) { return $result; } - } From 9ca3130fe368caa4718418a93de7d90853094af6 Mon Sep 17 00:00:00 2001 From: Sazedul Haque Date: Mon, 19 Oct 2026 15:55:10 +0600 Subject: [PATCH 3/4] Skip required validation for revealed answers Treat committed quiz answers as revealed and exempt them from required-answer validation, so users can continue without re-answering questions they've already submitted. --- assets/src/js/front/course/_spotlight-quiz.js | 3 +++ templates/learning-area/quiz/attempt.php | 3 ++- templates/learning-area/quiz/question.php | 9 ++++----- templates/single/quiz/parts/choice-box.php | 2 +- templates/single/quiz/parts/question.php | 2 +- 5 files changed, 11 insertions(+), 8 deletions(-) diff --git a/assets/src/js/front/course/_spotlight-quiz.js b/assets/src/js/front/course/_spotlight-quiz.js index f87a46b49e..e843a06c1a 100644 --- a/assets/src/js/front/course/_spotlight-quiz.js +++ b/assets/src/js/front/course/_spotlight-quiz.js @@ -135,6 +135,9 @@ window.jQuery(document).ready($ => { */ function tutor_quiz_validation($question_wrap, validated) { + if ($question_wrap.attr('data-revealed') === '1') { + return validated; + } var $required_answer_wrap = $question_wrap.find('.quiz-answer-required'); diff --git a/templates/learning-area/quiz/attempt.php b/templates/learning-area/quiz/attempt.php index 309ee5d51e..43e5353f97 100644 --- a/templates/learning-area/quiz/attempt.php +++ b/templates/learning-area/quiz/attempt.php @@ -217,7 +217,8 @@ class="tutor-quiz-questions" do_action( 'tutor_quiz/body/before', $tutor_is_started_quiz->quiz_id, $quiz_attempt_info ); foreach ( $questions as $index => $question ) { $question_settings = maybe_unserialize( $question->question_settings ); - $answer_required = isset( $question_settings['answer_required'] ) && '1' === $question_settings['answer_required']; + $is_committed_question = ! empty( $committed_attempt_answers[ (int) $question->question_id ] ); + $answer_required = ! $is_committed_question && isset( $question_settings['answer_required'] ) && '1' === $question_settings['answer_required']; $question_index = $index + 1; $question->committed_answer = $committed_attempt_answers[ (int) $question->question_id ] ?? null; ?> diff --git a/templates/learning-area/quiz/question.php b/templates/learning-area/quiz/question.php index 0c3cdb94d9..e44e92f8ef 100644 --- a/templates/learning-area/quiz/question.php +++ b/templates/learning-area/quiz/question.php @@ -49,12 +49,11 @@ $question_id = (int) ( $question->question_id ?? 0 ); $field_name_base = sprintf( 'attempt[%d][quiz_question][%d]', $attempt_id, $question_id ); -$answer_is_required = isset( $question_settings['answer_required'] ) && '1' === $question_settings['answer_required']; +$committed_answer = $committed_answer ?? ( $question->committed_answer ?? null ); +$is_revealed = ! empty( $committed_answer ); +$answer_is_required = ! $is_revealed && isset( $question_settings['answer_required'] ) && '1' === $question_settings['answer_required']; $required_message = __( 'The answer for this question is required', 'tutor' ); - -$committed_answer = $committed_answer ?? ( $question->committed_answer ?? null ); -$is_revealed = ! empty( $committed_answer ); -$reveal_result = ''; +$reveal_result = ''; if ( $is_revealed ) { if ( '1' === (string) $committed_answer->is_correct ) { $reveal_result = 'correct'; diff --git a/templates/single/quiz/parts/choice-box.php b/templates/single/quiz/parts/choice-box.php index 726423c459..6c0fcbef33 100644 --- a/templates/single/quiz/parts/choice-box.php +++ b/templates/single/quiz/parts/choice-box.php @@ -67,7 +67,7 @@ } ?> -
> +
>
diff --git a/templates/single/quiz/parts/question.php b/templates/single/quiz/parts/question.php index b38fa0aa7f..1e1a314033 100644 --- a/templates/single/quiz/parts/question.php +++ b/templates/single/quiz/parts/question.php @@ -123,7 +123,7 @@ class="quiz-attempt-single-question quiz-attempt-single-question-question_id, $rand_choice ); $show_question_mark = (bool) tutor_utils()->avalue_dot( 'show_question_mark', $question_settings ); - $answer_required = (bool) tutor_utils()->array_get( 'answer_required', $question_settings ); + $answer_required = ! $is_question_committed && (bool) tutor_utils()->array_get( 'answer_required', $question_settings ); echo wp_kses( '
', array( From 600e7d753fd70b61da28c9938e11855f77fd4052 Mon Sep 17 00:00:00 2001 From: Sazedul Haque Date: Mon, 19 Oct 2026 16:37:32 +0600 Subject: [PATCH 4/4] Handle missing content bank question flags Build the content bank flag map only for questions with an ID and use safe empty checks when filtering question and answer payloads, avoiding undefined-index notices. --- classes/QuizBuilder.php | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/classes/QuizBuilder.php b/classes/QuizBuilder.php index ef28599d6d..5cf125000b 100644 --- a/classes/QuizBuilder.php +++ b/classes/QuizBuilder.php @@ -942,15 +942,21 @@ private function set_current_quiz_questions_answer_ids( int $quiz_id ) { public function is_valid_quiz_question_answer_payload( array $payload ): bool { $payload_question_ids = wp_list_pluck( $payload, 'question_id' ); $payload_question_answers = wp_list_pluck( $payload, 'question_answers', 'question_id' ); - $is_cb_question = wp_list_pluck( $payload, 'is_cb_question', 'question_id' ); + + $is_cb_question = array(); + foreach ( $payload as $question ) { + if ( isset( $question['question_id'] ) ) { + $is_cb_question[ $question['question_id'] ] = ! empty( $question['is_cb_question'] ); + } + } // Remove content bank answers. - $payload_question_answers = array_filter( $payload_question_answers, fn( $question_id ) => ! $is_cb_question[ $question_id ], ARRAY_FILTER_USE_KEY ); + $payload_question_answers = array_filter( $payload_question_answers, fn( $question_id ) => empty( $is_cb_question[ $question_id ] ), ARRAY_FILTER_USE_KEY ); $payload_question_answers = array_filter( array_values( $payload_question_answers ) ); // Filter out empty array. $payload_answer_ids = wp_list_pluck( array_merge( ...$payload_question_answers ), 'answer_id' ); // Remove the hash id. - $payload_question_ids = array_filter( $payload_question_ids, fn( $id ) => is_numeric( $id ) && ! $is_cb_question[ $id ] ); + $payload_question_ids = array_filter( $payload_question_ids, fn( $id ) => is_numeric( $id ) && empty( $is_cb_question[ $id ] ) ); $payload_answer_ids = array_filter( $payload_answer_ids, fn( $id ) => is_numeric( $id ) ); // For the new quiz current_quiz is null, in this there should not be any numeric question or answer id.