Repository navigation
Verify quiz answers server-side in reveal mode to prevent answer leakage - #3057
Conversation
…r leakage - Remove pre-shipped encoded correct answer IDs and explanations from HTML templates and window context - Add secure `wp_ajax_tutor_quiz_check_answer` endpoint to verify answers per-question on demand - Integrate verification into modern Learning Area and decouple layout, submission, and explanation modules - Prevent in-flight double-click race conditions with verification state guards on navigation and submit buttons - Fix `quiz_answer_display_time` resolution from `window.TutorCore.config` - Lock question inputs upon reveal in legacy mode and re-enable before submission to preserve attempt grading
|
This solution still has a glitch when fetching attempt answers by extracting the required IDs from the DOM. We need to do further R&D to find a proper solution and determine a target version for ending legacy support. |
Safely encode the Alpine.js configuration used by the quiz attempt form by switching the `x-data` attribute to a double-quoted string and escaping embedded values with `esc_js`, `esc_attr`, and `tutor_json_encode`. This prevents malformed JavaScript when quiz IDs, form IDs, layout names, and default values contain quotes or special characters.
- Persist committed answers to tutor_quiz_attempt_answers upon verification - Add QuizModel methods for committed answers and aggregated stats - Hydrate answered questions, correct answer badges, and active index on page reload - Prevent empty verification AJAX calls and scope UI resets in legacy quiz mode - Seed question random ordering deterministically per attempt
…acy mode - Re-enable Next and Submit buttons once answer verification response is rendered - Clear reveal timeout when clicking Next, Previous, or Pagination items - Allow students to advance immediately without waiting for full reveal duration
…in legacy mode - Add is-loading class when triggering tutor_quiz_check_answer AJAX - Remove is-loading class once feedback is rendered
This updates the quiz flow to clear pending submit timers while navigating, prevent empty-answer submissions, and avoid blocking navigation while a submit is pending. It also aligns the UI state handling for verification and submit buttons, and fixes focus/hover styling so disabled quiz options no longer appear active.
Update the revealed quiz question markup to use the `data-reveal-result` attribute instead of `data-result`, so the reveal result is exposed consistently for client-side logic and scripts.
| ); | ||
|
|
||
| $answers_data = apply_filters( 'tutor_filter_quiz_answer_data', $answers_data, $question_id, $question->question_type, $user_id, $attempt_id ); | ||
|
|
There was a problem hiding this comment.
Why is there no tutor_filter_quiz_total_marks filter? Don't we need it?
There was a problem hiding this comment.
We don't need this here. For reveal mode we are fetching the total marks from database before inserting quit attempt using QuizModel::get_attempt_answers_stats( (int) $attempt_id );:
if ( $has_committed_answers ) {
$stats = QuizModel::get_attempt_answers_stats( (int) $attempt_id );
$earned_marks = max( 0.0, $stats->total_earned_marks );
$total_answered_questions = $stats->total_answered_count;
$review_required = $review_required || $stats->pending_review_count > 0;
}
|
|
||
| $wpdb->insert( $wpdb->prefix . 'tutor_quiz_attempt_answers', $answers_data ); | ||
|
|
||
| $explanation = apply_filters( 'tutor_quiz_question_answer_explanation', '', $question, $attempt ); |
There was a problem hiding this comment.
Why is the default value empty instead of the stored value?
There was a problem hiding this comment.
Answer explanation comes from Pro plugin.
| $this->json_response( __( 'Answer has already been committed for this question', 'tutor' ), null, HttpHelper::STATUS_BAD_REQUEST ); | ||
| } | ||
|
|
||
| $is_correct = false; |
There was a problem hiding this comment.
Why is the default value for is_correct false? What if the question requires manual review, like a short answer or an open-ended question?
There was a problem hiding this comment.
Reveal mode only supports following question types. So no manual review required.
$supported_types = array(
QuizModel::QUESTION_TYPE_TRUE_FALSE,
QuizModel::QUESTION_TYPE_SINGLE_CHOICE,
QuizModel::QUESTION_TYPE_MULTIPLE_CHOICE,
);
| 'question_mark' => $question_mark, | ||
| 'achieved_mark' => $achieved_mark, | ||
| 'minus_mark' => 0, | ||
| 'is_correct' => $is_correct ? QuizModel::ATTEMPT_ANSWER_CORRECT : QuizModel::ATTEMPT_ANSWER_INCORRECT, |
There was a problem hiding this comment.
Since the default value is set to = false
The question types that require manual review will show as incorrect by default?
There was a problem hiding this comment.
As mentioned before these are the only supported question types for reveal mode.
$supported_types = array(
QuizModel::QUESTION_TYPE_TRUE_FALSE,
QuizModel::QUESTION_TYPE_SINGLE_CHOICE,
QuizModel::QUESTION_TYPE_MULTIPLE_CHOICE,
);
Refactors spotlight quiz front-end logic in _spotlight-quiz.js: replace var with let/const, centralize form submission into submitQuizForm(), and introduce a single revealTimeoutId lifecycle (clear/set) to prevent races. Move loading/countdown handling into AJAX success paths, use get_reveal_wait_time() consistently, and switch complete callbacks to error to avoid duplicate post-request behavior. Overall improves scoping, prevents double-submits, and makes reveal/countdown UX more reliable.
This change hardens quiz answer reveal handling by requiring the attempt to still be in progress, using the attempt's quiz ID instead of a client-provided value, and validating supported question types before revealing answers. It also returns clearer errors for unsupported question types, disabled answer reveal, and failed answer insert attempts to avoid saving invalid quiz data.
| * | ||
| * @return object{total_earned_marks: float, total_answered_count: int, pending_review_count: int} | ||
| */ | ||
| public static function get_attempt_answers_stats( int $attempt_id ): object { |
There was a problem hiding this comment.
There is already a function calculate_attempt_earned_marks; we can expand that. Also, why are we keeping track of pending_review_count? This is already tracked by manage_attempt_answers
There was a problem hiding this comment.
I have removed the pending_review_count as there is no use case of it here. But if we want to expand calculate_attempt_earned_marks it requires changes in multiple places including return type. Instead we can update calculate_attempt_earned_marks as following. what do you say?
/**
* Calculate total earned marks for an attempt from individual answer records.
*
* @since 4.1.0
*
* @param int $attempt_id Attempt ID.
*
* @return float
*/
private function calculate_attempt_earned_marks( int $attempt_id ): float {
return QuizModel::get_attempt_answers_stats( $attempt_id )->total_earned_marks;
}
Loop through all attempt question IDs so questions with no submitted inputs are properly saved as skipped
This removes the pending-review flag from aggregated quiz attempt answer stats and stops setting review_required when any answer is still waiting for review. The stats query now reports only earned marks and answered count, simplifying the quiz attempt summary logic.
📌 Summary
Enhances the Quiz Answer Reveal feature by migrating to secure server-side answer verification, persisting revealed answer states across page reloads, and adding a countdown fill animation to navigation buttons during the reveal wait time.
🚀 Key Changes
Secure Server-Side Verification:
window.tutor_quiz_contextand pre-rendered correct answer IDs).wp_ajax_tutor_quiz_check_answerendpoint to verify questions on demand.State Persistence on Reload:
tutor_quiz_attempt_answersimmediately upon reveal.correct/incorrect) across reloads by standardizing thedata-reveal-resultattribute.Button Countdown Animation:
answers_reveal_duration.prefers-reduced-motion.Legacy Mode Parity & Bug Fixes: