prefix and fixed strings, and every // value goes through $wpdb->prepare() or $wpdb->insert/update/delete. // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching class Quiz_Engine { /** Per-site user option holding when the student opened a quiz: {name}{quiz_id}_{enrollment_id}. */ const START_OPTION = 'accessnow_slms_quiz_start_'; /** Seconds allowed past a time limit (network delay, and timers in background tabs run late). */ const TIME_GRACE = 60; /** "Show answers after a quiz" choices (post meta _slms_show_answers). */ const SHOW_ANSWERS = array( 'never', 'pass', 'final', 'always' ); /** Key added to a stored attempt's answers when the time ran out (late, or never submitted). */ const TIMED_OUT_KEY = '_slms_timed_out'; /** The types graded here; others come from accessnow_slms_question_types. */ const BUILTIN_TYPES = array( 'multiple_choice', 'true_false', 'fill_blank' ); /** Most items an answer to a list or map question may have. */ const MAX_ANSWER_ITEMS = 50; /** ID of the attempt record_attempt() stored last in this request. */ private static $last_attempt_id = 0; /** * The question types a quiz can use: type => label and answer_shape ('scalar': one value, 'list': * several, 'map': key => value). * * @return array */ public static function question_types() { $builtin = array( 'multiple_choice' => array( 'label' => __( 'Multiple Choice', 'accessnow-lms' ), 'answer_shape' => 'scalar', ), 'true_false' => array( 'label' => __( 'True / False', 'accessnow-lms' ), 'answer_shape' => 'scalar', ), 'fill_blank' => array( 'label' => __( 'Fill in the Blank', 'accessnow-lms' ), 'answer_shape' => 'scalar', ), ); $types = $builtin; /** * The question types quizzes can use. Add a type with a label and an answer_shape ('scalar', * 'list' or 'map'), then render it (accessnow_slms_render_question), grade it * (accessnow_slms_grade_question) and sanitise it (accessnow_slms_sanitize_question). * * A question whose type isn't registered (its add-on was switched off) is kept as it is, and * left out of the quiz page and the score. * * @since 1.1.0 * * @param array $types Type => array( 'label' => ..., 'answer_shape' => ... ). */ $types = (array) apply_filters( 'accessnow_slms_question_types', $types ); foreach ( self::BUILTIN_TYPES as $name ) { unset( $types[ $name ] ); // The built-in types can't be replaced or removed. } $clean = array(); foreach ( $types as $type => $info ) { $type = sanitize_key( $type ); if ( '' === $type || ! is_array( $info ) ) { continue; } $shape = isset( $info['answer_shape'] ) && in_array( $info['answer_shape'], array( 'scalar', 'list', 'map' ), true ) ? $info['answer_shape'] : 'scalar'; $clean[ $type ] = array( 'label' => isset( $info['label'] ) ? (string) $info['label'] : $type, 'answer_shape' => $shape, ); } return $builtin + $clean; } /** * How an answer to a question type is shaped: 'scalar', 'list' or 'map' ('' when not registered). * * @param string $type Question type. * @return string */ public static function answer_shape( $type ) { $types = self::question_types(); return isset( $types[ $type ] ) ? $types[ $type ]['answer_shape'] : ''; } /** * A quiz's questions, for one purpose. Questions of a type that isn't registered are left out, * except for 'hash' (change detection compares what is stored). * * @param int $quiz_id Quiz ID. * @param array $context user_id, enrollment_id, purpose ('render', 'grade', 'answers' or 'hash'). * @return array */ public static function get_questions( $quiz_id, $context = array() ) { $context = wp_parse_args( $context, array( 'user_id' => 0, 'enrollment_id' => 0, 'purpose' => 'render', ) ); $questions = get_post_meta( $quiz_id, '_slms_questions', true ); $questions = is_array( $questions ) ? array_values( array_filter( $questions, 'is_array' ) ) : array(); /** * A quiz's questions for one purpose: e.g. draw a few from a question bank. Use the same seed * for 'render', 'grade' and 'answers' in one attempt, so the student is graded on what they saw. * * @since 1.1.0 * * @param array $questions Stored questions. * @param int $quiz_id Quiz ID. * @param array $context user_id, enrollment_id, purpose ('render', 'grade', 'answers', 'hash'). */ $questions = apply_filters( 'accessnow_slms_quiz_questions', $questions, (int) $quiz_id, $context ); $questions = is_array( $questions ) ? array_values( array_filter( $questions, 'is_array' ) ) : array(); if ( 'hash' === $context['purpose'] ) { return $questions; } $types = self::question_types(); return array_values( array_filter( $questions, function ( $q ) use ( $types ) { return isset( $q['id'], $q['type'] ) && isset( $types[ $q['type'] ] ); } ) ); } public static function init() { // After Access_Control::check_access(), so only people allowed to see the quiz start one. add_action( 'template_redirect', array( __CLASS__, 'start_on_view' ), 20 ); } /** * The quiz's time limit in seconds (0 = none). * * @param int $quiz_id Quiz ID. * @return int */ public static function time_limit_seconds( $quiz_id ) { return max( 0, (int) get_post_meta( $quiz_id, '_slms_time_limit', true ) ) * MINUTE_IN_SECONDS; } /** * When the student started the current attempt (Unix time), or 0. * * @param int $user_id User ID. * @param int $quiz_id Quiz ID. * @param int $enrollment_id Enrolment ID. * @return int */ public static function get_attempt_start( $user_id, $quiz_id, $enrollment_id ) { return (int) get_user_option( self::START_OPTION . (int) $quiz_id . '_' . (int) $enrollment_id, $user_id ); } /** * Record the start of an attempt when the quiz form is shown. * * A timed attempt that is still running is resumed, so reloading the page or opening * another tab doesn't reset the clock. * * @param int $user_id User ID. * @param int $quiz_id Quiz ID. * @param int $enrollment_id Enrolment ID. * @return int Start time (Unix). */ public static function start_attempt( $user_id, $quiz_id, $enrollment_id ) { $start = self::get_attempt_start( $user_id, $quiz_id, $enrollment_id ); $limit = self::time_limit_seconds( $quiz_id ); if ( $start && $limit && time() <= $start + $limit + self::TIME_GRACE ) { return $start; } $start = time(); update_user_option( $user_id, self::START_OPTION . (int) $quiz_id . '_' . (int) $enrollment_id, $start ); return $start; } /** * Forget the start once the attempt is submitted, so the next one gets a fresh clock. * * @param int $user_id User ID. * @param int $quiz_id Quiz ID. * @param int $enrollment_id Enrolment ID. */ public static function clear_attempt_start( $user_id, $quiz_id, $enrollment_id ) { delete_user_option( $user_id, self::START_OPTION . (int) $quiz_id . '_' . (int) $enrollment_id ); } /** * Whether a timed attempt that started at $start is past its limit plus the grace. * * @param int $quiz_id Quiz ID. * @param int $start Start time (Unix). * @return bool */ public static function is_expired( $quiz_id, $start ) { $limit = self::time_limit_seconds( $quiz_id ); return $start && $limit && time() > $start + $limit + self::TIME_GRACE; } /** * Zero a graded result: the time ran out, so the attempt counts but scores nothing. * * @param array $result Result from grade(). * @return array */ public static function zero_result( $result ) { $result['score'] = 0; $result['percentage'] = 0; $result['passed'] = false; $result['pending'] = false; foreach ( $result['results'] as &$row ) { $row['correct'] = false; $row['pending'] = false; $row['earned'] = 0; } unset( $row ); return $result; } /** * Record a timed attempt that ran out without being submitted as a 0-score attempt. * * Without this, a student could let the clock run out, reopen the quiz after the grace and * get a fresh clock with no attempt used: unlimited time to read the questions first. * * @param int $user_id User ID. * @param int $quiz_id Quiz ID. * @param int $course_id Course ID. * @param int $enrollment_id Enrolment ID. * @return bool Whether an attempt was recorded. */ public static function expire_abandoned( $user_id, $quiz_id, $course_id, $enrollment_id ) { $start = self::get_attempt_start( $user_id, $quiz_id, $enrollment_id ); if ( ! self::is_expired( $quiz_id, $start ) ) { return false; } // Clear first, so two requests at once can't both record it. self::clear_attempt_start( $user_id, $quiz_id, $enrollment_id ); self::record_attempt( $user_id, $quiz_id, $course_id, array( self::TIMED_OUT_KEY => 1 ), self::zero_result( self::grade( $quiz_id, array(), array( 'user_id' => $user_id, 'enrollment_id' => $enrollment_id ) ) ), $enrollment_id, $start, $start + self::time_limit_seconds( $quiz_id ) ); /** * A timed attempt ran out without being submitted and was recorded as "Timed out". * * @since 1.1.0 * * @param int $user_id User ID. * @param int $quiz_id Quiz ID. * @param int $attempt_id The recorded attempt's ID. * @param int $enrollment_id Enrolment ID. */ do_action( 'accessnow_slms_quiz_attempt_expired', (int) $user_id, (int) $quiz_id, self::$last_attempt_id, (int) $enrollment_id ); return true; } /** * Whether a stored attempt ran out of time. * * @param object $attempt Row from slms_quiz_attempts. * @return bool */ public static function is_timed_out( $attempt ) { $answers = isset( $attempt->answers ) ? maybe_unserialize( $attempt->answers ) : array(); return is_array( $answers ) && ! empty( $answers[ self::TIMED_OUT_KEY ] ); } /** * "Passed", "Failed", "Timed out" or "Awaiting review" for a stored attempt. * * @param object $attempt Row from slms_quiz_attempts. * @return string */ public static function attempt_status_label( $attempt ) { if ( isset( $attempt->status ) && 'pending' === $attempt->status ) { return __( 'Awaiting review', 'accessnow-lms' ); } if ( $attempt->passed ) { return __( 'Passed', 'accessnow-lms' ); } return self::is_timed_out( $attempt ) ? __( 'Timed out', 'accessnow-lms' ) : __( 'Failed', 'accessnow-lms' ); } /** * Start the clock when someone who may take the quiz opens its page. * * Done here rather than in the template so theme overrides of single-slms_quiz.php still start it. */ public static function start_on_view() { if ( is_admin() || ! is_user_logged_in() || ! is_singular( 'slms_quiz' ) ) { return; } $quiz_id = get_the_ID(); $user_id = get_current_user_id(); $course_id = (int) Access_Control::get_course_id_from_content( $quiz_id, 'slms_quiz' ); $enrollment = $course_id ? Enrollment::get_accessible_enrollment( $user_id, $course_id ) : null; if ( ! $enrollment && ! Capabilities::can_preview( $quiz_id ) ) { return; } $enrollment_id = $enrollment ? (int) $enrollment->id : 0; // Before can_retry(): the abandoned attempt may use up the last one. self::expire_abandoned( $user_id, $quiz_id, $course_id, $enrollment_id ); $questions = self::get_questions( $quiz_id, array( 'user_id' => $user_id, 'enrollment_id' => $enrollment_id ) ); if ( empty( $questions ) || ! self::can_retry( $user_id, $quiz_id, $enrollment_id ) ) { return; } self::start_attempt( $user_id, $quiz_id, $enrollment_id ); } /** * A quiz's "Show answers after a quiz" setting. * * @param int $quiz_id Quiz ID. * @return string One of SHOW_ANSWERS ('never' when unset). */ public static function show_answers_setting( $quiz_id ) { $value = (string) get_post_meta( $quiz_id, '_slms_show_answers', true ); return in_array( $value, self::SHOW_ANSWERS, true ) ? $value : 'never'; } /** * Whether this attempt's result may show the correct answers and explanations. * * @param int $quiz_id Quiz ID. * @param bool $passed Whether the attempt passed. * @param bool $can_retry Whether the student may try again. * @return bool */ public static function may_show_answers( $quiz_id, $passed, $can_retry ) { switch ( self::show_answers_setting( $quiz_id ) ) { case 'always': $show = true; break; case 'pass': $show = (bool) $passed; break; case 'final': $show = ! $can_retry; break; default: $show = false; } /** * Whether a quiz result shows the correct answers and explanations. * * @since 1.0.0 * * @param bool $show From the quiz's "Show answers after a quiz" setting. * @param int $quiz_id Quiz ID. * @param bool $passed Whether the attempt passed. * @param bool $can_retry Whether the student may try again. */ return (bool) apply_filters( 'accessnow_slms_show_answers', $show, (int) $quiz_id, (bool) $passed, (bool) $can_retry ); } /** * The correct answer to a question, as the student would read it. * * @param array $q Question. * @return string */ public static function correct_answer_text( $q ) { if ( ! in_array( $q['type'], self::BUILTIN_TYPES, true ) ) { /** * The correct answer to a question of an added type, as the student would read it. * * @since 1.1.0 * * @param string $text '' (nothing shown). * @param array $q Question. */ return (string) apply_filters( 'accessnow_slms_correct_answer_text', '', $q ); } if ( 'fill_blank' === $q['type'] ) { return implode( ' / ', array_filter( array_map( 'trim', explode( '|', (string) $q['correct'] ) ), 'strlen' ) ); } if ( 'true_false' === $q['type'] ) { return 0 === (int) $q['correct'] ? __( 'True', 'accessnow-lms' ) : __( 'False', 'accessnow-lms' ); } $options = isset( $q['options'] ) && is_array( $q['options'] ) ? $q['options'] : array(); return isset( $options[ (int) $q['correct'] ] ) ? (string) $options[ (int) $q['correct'] ] : ''; } /** * A submitted answer as the student would read it ('' when blank or not an option). * * @param array $q Question. * @param mixed $answer Submitted answer. * @return string */ public static function answer_text( $q, $answer ) { if ( ! in_array( $q['type'], self::BUILTIN_TYPES, true ) ) { /** * A student's answer to a question of an added type, as they would read it. * * @since 1.1.0 * * @param string $text '' (nothing shown). * @param array $q Question. * @param mixed $answer Submitted answer. */ return (string) apply_filters( 'accessnow_slms_answer_text', '', $q, $answer ); } if ( 'fill_blank' === $q['type'] ) { return is_scalar( $answer ) ? trim( (string) $answer ) : ''; } if ( ! self::is_valid_choice( $answer, $q ) ) { return ''; } $q['correct'] = (int) $answer; return self::correct_answer_text( $q ); } /** * Add each question's correct answer (when it was wrong) and explanation to graded results. * * @param int $quiz_id Quiz ID. * @param array $results Results from grade(). * @param array $context user_id and enrollment_id (see get_questions()). * @return array */ public static function add_answers( $quiz_id, array $results, $context = array() ) { $questions = self::get_questions( $quiz_id, array_merge( $context, array( 'purpose' => 'answers' ) ) ); $by_id = array(); foreach ( $questions as $q ) { $by_id[ $q['id'] ] = $q; } foreach ( $results as &$row ) { $q = isset( $by_id[ $row['question_id'] ] ) ? $by_id[ $row['question_id'] ] : null; if ( ! $q ) { continue; } $row['correct_answer'] = $row['correct'] ? '' : self::correct_answer_text( $q ); $row['user_answer_text'] = $row['correct'] ? '' : self::answer_text( $q, $row['user_answer'] ); $row['explanation'] = isset( $q['explanation'] ) ? (string) $q['explanation'] : ''; } unset( $row ); return $results; } /** * Whether a multiple-choice or true/false answer is one of the question's options. * * @param mixed $answer Submitted answer. * @param array $q Question. * @return bool */ private static function is_valid_choice( $answer, $q ) { if ( ! is_scalar( $answer ) || ! ctype_digit( (string) $answer ) ) { return false; // Blank, missing or not an option index. } $keys = 'true_false' === $q['type'] ? array( 0, 1 ) : array_keys( isset( $q['options'] ) && is_array( $q['options'] ) ? $q['options'] : array() ); return in_array( (int) $answer, array_map( 'intval', $keys ), true ); } /** * Deterministic "shuffle": the same keys and seed always give the same order, * without reseeding PHP's global random number generator. * * @param array $keys Keys to order. * @param string $seed Seed. * @return array */ public static function seeded_order( array $keys, $seed ) { $weights = array(); foreach ( $keys as $key ) { $weights[ $key ] = md5( $seed . '|' . $key ); } asort( $weights, SORT_STRING ); return array_keys( $weights ); } /** * A student's most recent quiz attempts (dashboard). * * @param int $user_id User ID. * @param int $limit Number of attempts. * @return array */ public static function get_recent_attempts( $user_id, $limit = 10 ) { global $wpdb; return $wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$wpdb->prefix}slms_quiz_attempts WHERE user_id = %d ORDER BY completed_at DESC LIMIT %d", $user_id, $limit ) ); } /** * Grade answers against a quiz's questions. * * Built-in types are graded here; added types by accessnow_slms_grade_question. A question nobody * grades is left out of the score. A question graded "pending" (marked by hand later) makes the * whole attempt pending: not passed until update_attempt_result() records the marks. * * @param int $quiz_id Quiz ID. * @param array $user_answers Answers keyed by question ID. * @param array $context user_id and enrollment_id (see get_questions()). * @return array score, total, percentage, passed, pending, results. */ public static function grade( $quiz_id, $user_answers, $context = array() ) { $context = array_merge( (array) $context, array( 'purpose' => 'grade' ) ); $questions = self::get_questions( $quiz_id, $context ); if ( empty( $questions ) ) { return self::filter_grade( array( 'score' => 0, 'total' => 0, 'percentage' => 0, 'passed' => false, 'pending' => false, 'results' => array(), ), $quiz_id, $user_answers ); } $total_points = 0; $earned_points = 0; $pending = false; $results = array(); foreach ( $questions as $q ) { $points = isset( $q['points'] ) ? (float) $q['points'] : 0; $user_answer = isset( $user_answers[ $q['id'] ] ) ? $user_answers[ $q['id'] ] : ''; $is_pending = false; if ( 'multiple_choice' === $q['type'] || 'true_false' === $q['type'] ) { // (int) '' is 0, so a blank answer must not be compared with option 0 ("True", or the first option). $is_correct = self::is_valid_choice( $user_answer, $q ) && (int) $user_answer === (int) $q['correct']; $earned = $is_correct ? $points : 0; } elseif ( 'fill_blank' === $q['type'] ) { $user_trimmed = self::normalise_answer( is_scalar( $user_answer ) ? $user_answer : '' ); $accepted = array_map( array( __CLASS__, 'normalise_answer' ), explode( '|', (string) $q['correct'] ) ); $is_correct = in_array( $user_trimmed, $accepted, true ); $earned = $is_correct ? $points : 0; } else { /** * Grade one question of an added type. Return null to leave it out of the score, or * array( 'earned' => points (0 to its points), 'correct' => bool, 'pending' => bool ) * (pending: it is marked by hand later). * * @since 1.1.0 * * @param array|null $grade Null. * @param array $q Question. * @param mixed $answer The student's answer. * @param int $quiz_id Quiz ID. * @param array $context user_id, enrollment_id, purpose. */ $graded = apply_filters( 'accessnow_slms_grade_question', null, $q, $user_answer, (int) $quiz_id, $context ); if ( ! is_array( $graded ) ) { continue; } $is_pending = ! empty( $graded['pending'] ); $earned = $is_pending ? 0 : max( 0, min( $points, (float) ( $graded['earned'] ?? 0 ) ) ); $is_correct = ! $is_pending && ! empty( $graded['correct'] ); } $total_points += $points; $earned_points += $earned; $pending = $pending || $is_pending; $results[] = array( 'question_id' => $q['id'], 'correct' => $is_correct, 'pending' => $is_pending, 'earned' => $earned, 'user_answer' => $user_answer, 'text' => isset( $q['text'] ) ? $q['text'] : '', ); } $percentage = $total_points > 0 ? round( ( $earned_points / $total_points ) * 100, 2 ) : 0; $pass_threshold = (int) get_post_meta( $quiz_id, '_slms_pass_threshold', true ); $passed = ! $pending && ( $pass_threshold <= 0 || $percentage >= $pass_threshold ); return self::filter_grade( array( 'score' => $earned_points, 'total' => $total_points, 'percentage' => $percentage, 'passed' => $passed, 'pending' => $pending, 'results' => $results, ), $quiz_id, $user_answers ); } /** * A fill-in-the-blank answer in comparable form: trimmed, lower case, and with full-width * letters, digits and spaces folded to half-width ("ABC1" matches "abc1") so Japanese * keyboards match. Uses mbstring when it is installed. * * @param string $answer Answer. * @return string */ public static function normalise_answer( $answer ) { $answer = trim( (string) $answer ); if ( function_exists( 'mb_convert_kana' ) ) { $answer = mb_convert_kana( $answer, 'asKV', 'UTF-8' ); // Full-width alphanumerics and spaces to half-width; half-width katakana to full-width. $answer = trim( $answer ); } return function_exists( 'mb_strtolower' ) ? mb_strtolower( $answer, 'UTF-8' ) : strtolower( $answer ); } /** * Apply accessnow_slms_grade_result. * * @param array $result Graded result. * @param int $quiz_id Quiz ID. * @param array $user_answers Answers keyed by question ID. * @return array */ private static function filter_grade( $result, $quiz_id, $user_answers ) { /** * The graded result of a quiz attempt, before it is stored. * * Runs before the time limit is applied: a late submission still scores 0 afterwards. * * @since 1.0.0 * * @param array $result { * @type float $score Points earned. * @type float $total Points available. * @type float $percentage Score as a percentage (stored, and compared with the pass mark). * @type bool $passed Whether the attempt passed (never while pending). * @type bool $pending Whether a question waits to be marked by hand (since 1.1.0). * @type array $results Per question: question_id, correct, pending, earned, user_answer, text. * } * @param int $quiz_id Quiz ID. * @param array $user_answers Answers keyed by question ID. */ return apply_filters( 'accessnow_slms_grade_result', $result, (int) $quiz_id, $user_answers ); } public static function record_attempt( $user_id, $quiz_id, $course_id, $answers, $grade_result, $enrollment_id = 0, $started_at = 0, $completed_at = 0 ) { global $wpdb; $now = $completed_at ? gmdate( 'Y-m-d H:i:s', (int) $completed_at ) : current_time( 'mysql', true ); $started = $started_at ? gmdate( 'Y-m-d H:i:s', (int) $started_at ) : $now; // Atomic INSERT...SELECT to compute attempt_number without race condition $wpdb->query( $wpdb->prepare( "INSERT INTO {$wpdb->prefix}slms_quiz_attempts (user_id, quiz_id, course_id, enrollment_id, attempt_number, score, passed, status, answers, started_at, completed_at) SELECT %d, %d, %d, %d, COALESCE(MAX(attempt_number), 0) + 1, %f, %d, %s, %s, %s, %s FROM {$wpdb->prefix}slms_quiz_attempts WHERE user_id = %d AND quiz_id = %d AND enrollment_id = %d", $user_id, $quiz_id, $course_id, $enrollment_id, $grade_result['percentage'], $grade_result['passed'] ? 1 : 0, empty( $grade_result['pending'] ) ? 'graded' : 'pending', maybe_serialize( $answers ), $started, $now, $user_id, $quiz_id, $enrollment_id ) ); self::$last_attempt_id = (int) $wpdb->insert_id; // Return the attempt number that was just inserted return (int) $wpdb->get_var( $wpdb->prepare( "SELECT attempt_number FROM {$wpdb->prefix}slms_quiz_attempts WHERE id = %d", self::$last_attempt_id ) ); } /** * ID of the attempt record_attempt() stored last in this request (0 when none). * * @return int */ public static function last_attempt_id() { return self::$last_attempt_id; } /** * Store a new result for an attempt: marks given by hand, or a regrade. * * Fires accessnow_slms_quiz_regraded, on which the plugin's quiz points and pass email listen, so * a pending attempt that is marked as a pass earns them then (once). * * @param int $attempt_id Attempt ID. * @param array $result percentage, passed, and pending (true while something is still unmarked). * @return bool False when there is no such attempt. */ public static function update_attempt_result( $attempt_id, array $result ) { global $wpdb; $table = "{$wpdb->prefix}slms_quiz_attempts"; $old = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$wpdb->prefix}slms_quiz_attempts WHERE id = %d", $attempt_id ) ); if ( ! $old ) { return false; } $pending = ! empty( $result['pending'] ); $passed = ! $pending && ! empty( $result['passed'] ); $wpdb->update( $table, array( 'score' => isset( $result['percentage'] ) ? (float) $result['percentage'] : (float) $old->score, 'passed' => $passed ? 1 : 0, 'status' => $pending ? 'pending' : 'graded', ), array( 'id' => (int) $attempt_id ), array( '%f', '%d', '%s' ), array( '%d' ) ); $result['passed'] = $passed; $result['pending'] = $pending; /** * An attempt's result changed after it was submitted (marked by hand, or regraded). * * @since 1.1.0 * * @param int $attempt_id Attempt ID. * @param object $old The attempt row before the change. * @param array $result The new result: percentage, passed, pending (and anything else passed in). */ do_action( 'accessnow_slms_quiz_regraded', (int) $attempt_id, $old, $result ); return true; } /** * Whether a student has an attempt at a quiz waiting to be marked. * * @param int $user_id User ID. * @param int $quiz_id Quiz ID. * @param int $enrollment_id Enrolment ID. * @return bool */ public static function has_pending_attempt( $user_id, $quiz_id, $enrollment_id = 0 ) { global $wpdb; return (bool) $wpdb->get_var( $wpdb->prepare( "SELECT id FROM {$wpdb->prefix}slms_quiz_attempts WHERE user_id = %d AND quiz_id = %d AND enrollment_id = %d AND status = 'pending' LIMIT 1", $user_id, $quiz_id, $enrollment_id ) ); } public static function can_retry( $user_id, $quiz_id, $enrollment_id = 0 ) { // No new attempt while one waits to be marked (it might be the pass). if ( self::has_pending_attempt( $user_id, $quiz_id, $enrollment_id ) ) { return false; } $max_retries = (int) get_post_meta( $quiz_id, '_slms_max_retries', true ); if ( -1 === $max_retries ) { return true; // unlimited } // Allow extra retries when lesson progress has been invalidated due to content changes $lesson_id = get_post_meta( $quiz_id, '_slms_parent_lesson', true ); if ( $lesson_id ) { $status = Progress::get_lesson_status( $user_id, $lesson_id, $enrollment_id ); if ( 'invalidated' === $status ) { return true; } } $attempts = self::count_attempts( $user_id, $quiz_id, $enrollment_id ); return $attempts <= $max_retries; } public static function count_attempts( $user_id, $quiz_id, $enrollment_id = 0 ) { global $wpdb; return (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->prefix}slms_quiz_attempts WHERE user_id = %d AND quiz_id = %d AND enrollment_id = %d", $user_id, $quiz_id, $enrollment_id ) ); } public static function get_best_attempt( $user_id, $quiz_id, $enrollment_id = 0 ) { global $wpdb; return $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$wpdb->prefix}slms_quiz_attempts WHERE user_id = %d AND quiz_id = %d AND enrollment_id = %d ORDER BY score DESC LIMIT 1", $user_id, $quiz_id, $enrollment_id ) ); } /** * Get best attempt made after a given datetime (for re-validation after content changes). */ public static function get_best_attempt_after( $user_id, $quiz_id, $enrollment_id = 0, $after_datetime = '' ) { global $wpdb; return $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$wpdb->prefix}slms_quiz_attempts WHERE user_id = %d AND quiz_id = %d AND enrollment_id = %d AND completed_at > %s ORDER BY score DESC LIMIT 1", $user_id, $quiz_id, $enrollment_id, $after_datetime ) ); } public static function get_attempts( $user_id, $quiz_id, $enrollment_id = 0 ) { global $wpdb; return $wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$wpdb->prefix}slms_quiz_attempts WHERE user_id = %d AND quiz_id = %d AND enrollment_id = %d ORDER BY attempt_number ASC", $user_id, $quiz_id, $enrollment_id ) ); } }