PluginProbe ʕ •ᴥ•ʔ
Tutor LMS – eLearning and online course solution / 4.0.5
Tutor LMS – eLearning and online course solution v4.0.5
4.0.5 4.0.4 4.0.3 4.0.2 4.0.1 4.0.0 3.9.15 3.9.14 3.9.13 3.9.12 3.9.11 trunk 1.0.0 1.0.0-alpha 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.1.1 1.2.0 1.2.1 1.2.11 1.2.12 1.2.13 1.2.20 1.3.0 1.3.1 1.3.2 1.3.3 1.3.4 1.3.5 1.3.6 1.3.7 1.3.8 1.3.9 1.4.0 1.4.1 1.4.2 1.4.3 1.4.4 1.4.5 1.4.6 1.4.7 1.4.8 1.4.9 1.5.0 1.5.1 1.5.2 1.5.3 1.5.4 1.5.5 1.5.6 1.5.7 1.5.8 1.5.9 1.6.0 1.6.1 1.6.2 1.6.3 1.6.4 1.6.5 1.6.6 1.6.7 1.6.8 1.6.9 1.7.0 1.7.1 1.7.2 1.7.3 1.7.4 1.7.5 1.7.6 1.7.7 1.7.8 1.7.9 1.8.0 1.8.1 1.8.10 1.8.2 1.8.3 1.8.4 1.8.5 1.8.6 1.8.7 1.8.8 1.8.9 1.9.0 1.9.1 1.9.10 1.9.11 1.9.12 1.9.13 1.9.14 1.9.15 1.9.16 1.9.2 1.9.3 1.9.4 1.9.5 1.9.6 1.9.7 1.9.8 1.9.9 2.0.0 2.0.1 2.0.10 2.0.2 2.0.3 2.0.4 2.0.5 2.0.6 2.0.7 2.0.8 2.0.9 2.1.0 2.1.1 2.1.10 2.1.2 2.1.3 2.1.4 2.1.5 2.1.6 2.1.7 2.1.8 2.1.9 2.2.0 2.2.1 2.2.2 2.2.3 2.2.4 2.3.0 2.4.0 2.5.0 2.6.0 2.6.1 2.6.2 2.7.0 2.7.1 2.7.2 2.7.3 2.7.4 2.7.5 2.7.6 2.7.7 3.0.0 3.0.1 3.0.2 3.1.0 3.2.0 3.2.1 3.2.2 3.2.3 3.3.0 3.3.1 3.4.0 3.4.1 3.4.2 3.5.0 3.6.0 3.6.1 3.6.2 3.6.3 3.6.4 3.7.0 3.7.1 3.7.2 3.7.3 3.7.4 3.8.0 3.8.1 3.8.2 3.8.3 3.9.0 3.9.1 3.9.10 3.9.2 3.9.3 3.9.4 3.9.5 3.9.6 3.9.7 3.9.8 3.9.9
tutor / classes / Quiz.php
tutor / classes Last commit date
Addons.php 1 month ago Admin.php 3 weeks ago Ajax.php 1 month ago Announcements.php 1 month ago Assets.php 2 weeks ago Backend_Page_Trait.php 1 year ago BaseController.php 1 year ago Config.php 1 month ago Container.php 1 year ago Course.php 3 days ago Course_Embed.php 3 years ago Course_Filter.php 1 month ago Course_List.php 1 month ago Course_Settings_Tabs.php 1 month ago Course_Widget.php 1 year ago Custom_Validation.php 1 month ago Dashboard.php 1 month ago Earnings.php 10 months ago FormHandler.php 1 month ago Frontend.php 1 month ago Gutenberg.php 1 year ago Icon.php 3 days ago Input.php 1 month ago Instructor.php 1 month ago Instructors_List.php 1 month ago Lesson.php 1 month ago Options_V2.php 1 month ago Permalink.php 1 month ago Post_types.php 1 month ago Private_Course_Access.php 1 month ago Q_And_A.php 1 month ago Question_Answers_List.php 1 year ago Quiz.php 3 days ago QuizBuilder.php 3 days ago Quiz_Attempts_List.php 1 month ago RestAPI.php 1 month ago Reviews.php 1 month ago Rewrite_Rules.php 2 years ago SampleCourse.php 1 month ago Shortcode.php 1 month ago Singleton.php 1 year ago Student.php 1 month ago Students_List.php 1 year ago Taxonomies.php 1 year ago Template.php 1 month ago Theme_Compatibility.php 3 years ago Tools.php 1 year ago Tools_V2.php 2 months ago Tutor.php 3 days ago TutorEDD.php 1 month ago Tutor_Base.php 2 years ago Tutor_Setup.php 1 month ago Upgrader.php 1 month ago User.php 2 weeks ago UserPreference.php 1 month ago Utils.php 3 days ago Video_Stream.php 3 years ago WhatsNew.php 1 month ago Withdraw.php 1 month ago Withdraw_Requests_List.php 1 year ago WooCommerce.php 3 days ago
Quiz.php
2307 lines
1 <?php
2 /**
3 * Quiz class
4 *
5 * @package Tutor\QuestionAnswer
6 * @author Themeum <support@themeum.com>
7 * @link https://themeum.com
8 * @since 1.0.0
9 */
10
11 namespace TUTOR;
12
13 defined( 'ABSPATH' ) || exit;
14
15 use Tutor\Components\Constants\Size;
16 use Tutor\Components\Constants\Variant;
17 use Tutor\Components\Button;
18 use Tutor\Components\ConfirmationModal;
19 use Tutor\Components\Modal;
20 use Tutor\Components\SvgIcon;
21 use Tutor\Components\Table;
22 use Tutor\Helpers\HttpHelper;
23 use Tutor\Helpers\QueryHelper;
24 use Tutor\Models\CourseModel;
25 use Tutor\Models\QuizModel;
26 use Tutor\Traits\JsonResponse;
27 use WP_Post;
28
29 /**
30 * Manage quiz operations.
31 *
32 * @since 1.0.0
33 */
34 class Quiz {
35 use JsonResponse;
36
37 /**
38 * Quiz post type
39 *
40 * @since 4.0.0
41 *
42 * @var string
43 */
44 private $post_type;
45
46 const META_QUIZ_OPTION = 'tutor_quiz_option';
47
48 /**
49 * Quiz feedback mode: show result after the attempt.
50 *
51 * @since 4.0.0
52 */
53 const QUIZ_FEEDBACK_MODE_REVEAL = 'reveal';
54
55 /**
56 * Quiz feedback mode: reattempt quiz any number of times.
57 *
58 * @since 4.0.0
59 */
60 const QUIZ_FEEDBACK_MODE_RETRY = 'retry';
61
62 /**
63 * Quiz feedback mode: answers shown after quiz is finished.
64 *
65 * @since 4.0.0
66 */
67 const QUIZ_FEEDBACK_MODE_DEFAULT = 'default';
68
69 /**
70 * URL Query param
71 *
72 * @since 4.0.0
73 */
74 const ACTION_VIEW_DETAILS = 'view_details';
75
76 /**
77 * Allowed attrs
78 *
79 * @var array
80 */
81 private $allowed_attributes = array(
82 'src' => array(),
83 'style' => array(),
84 'class' => array(),
85 'id' => array(),
86 'href' => array(),
87 'alt' => array(),
88 'title' => array(),
89 'type' => array(),
90 'controls' => array(),
91 'muted' => array(),
92 'loop' => array(),
93 'poster' => array(),
94 'preload' => array(),
95 'autoplay' => array(),
96 'width' => array(),
97 'height' => array(),
98 );
99
100 /**
101 * Allowed HTML tags
102 *
103 * @var array
104 */
105 private $allowed_html = array( 'img', 'b', 'i', 'br', 'a', 'audio', 'video', 'source' );
106
107 /**
108 * Register hooks
109 *
110 * @since 1.0.0
111 * @since 4.0.0 $register_hooks param added
112 *
113 * @param bool $register_hooks To register hooks.
114 *
115 * @return void
116 */
117 public function __construct( $register_hooks = true ) {
118 $this->post_type = tutor()->quiz_post_type;
119 $this->prepare_allowed_html();
120
121 if ( ! $register_hooks ) {
122 return;
123 }
124
125 add_action( 'wp_ajax_tutor_quiz_timeout', array( $this, 'tutor_quiz_timeout' ) );
126
127 // User take the quiz.
128 add_action( 'template_redirect', array( $this, 'start_the_quiz' ) );
129 add_action( 'template_redirect', array( $this, 'answering_quiz' ) );
130 add_action( 'template_redirect', array( $this, 'finishing_quiz_attempt' ) );
131
132 /**
133 * Instructor quiz review and feedback Ajax API.
134 */
135 add_action( 'wp_ajax_review_quiz_answer', array( $this, 'review_quiz_answer' ) );
136 add_action( 'wp_ajax_tutor_review_quiz_answers', array( $this, 'review_quiz_answers' ) );
137 add_action( 'wp_ajax_tutor_instructor_feedback', array( $this, 'tutor_instructor_feedback' ) );
138
139 /**
140 * New quiz builder Ajax API.
141 */
142 add_action( 'wp_ajax_tutor_quiz_details', array( $this, 'ajax_quiz_details' ) );
143 add_action( 'wp_ajax_tutor_quiz_delete', array( $this, 'ajax_quiz_delete' ) );
144
145 /**
146 * Frontend Stuff
147 */
148 add_action( 'wp_ajax_tutor_render_quiz_content', array( $this, 'tutor_render_quiz_content' ) );
149
150 /**
151 * Quiz abandon action
152 *
153 * @since 1.9.6
154 */
155 add_action( 'wp_ajax_tutor_quiz_abandon', array( $this, 'tutor_quiz_abandon' ) );
156
157 /**
158 * Delete quiz attempt
159 *
160 * @since 2.1.0
161 */
162 add_action( 'wp_ajax_tutor_attempt_delete', array( $this, 'attempt_delete' ) );
163
164 add_action( 'tutor_quiz/answer/review/after', array( $this, 'do_auto_course_complete' ), 10, 3 );
165
166 // Add quiz title as nav item & render single content on the learning area.
167 add_action( "tutor_learning_area_nav_item_{$this->post_type}", array( $this, 'render_nav_item' ), 10, 2 );
168 add_action( "tutor_single_content_{$this->post_type}", array( $this, 'render_single_content' ) );
169
170 /**
171 * Slugs listed in tutor_quiz_templates_not_in_core have no file under wp-content/plugins/tutor/templates/.
172 * Without this, tutor_load_template() still runs from generic quiz templates and tutor_get_template()
173 * prints "The file you are trying to load does not exist…". Returning false here exits before that lookup.
174 * Add-ons ship their own files and load them outside this path (e.g. direct include from the add-on).
175 *
176 * @since 4.0.0
177 */
178 add_filter( 'should_tutor_load_template', array( $this, 'skip_addon_only_question_partials' ), 5, 3 );
179 }
180
181 /**
182 * Skip loading templates that are not packaged with core Tutor LMS.
183 *
184 * @since 4.0.0
185 *
186 * @param bool $load Whether to load the template.
187 * @param string $template Template name in dot notation.
188 * @param array $variables Template variables.
189 *
190 * @return bool
191 */
192 public function skip_addon_only_question_partials( $load, $template, $variables ) {
193 $addons_only = apply_filters(
194 'tutor_quiz_templates_not_in_core',
195 array(
196 'learning-area.quiz.questions.draw-image',
197 'learning-area.quiz.questions.scale',
198 'learning-area.quiz.questions.pin-image',
199 'learning-area.quiz.questions.coordinates',
200 'learning-area.quiz.questions.puzzle',
201 'shared.components.quiz.attempt-details.questions.draw-image',
202 'shared.components.quiz.attempt-details.questions.scale',
203 'shared.components.quiz.attempt-details.questions.pin-image',
204 'shared.components.quiz.attempt-details.questions.coordinates',
205 'shared.components.quiz.attempt-details.questions.puzzle',
206 ),
207 $template,
208 $variables
209 );
210
211 if ( in_array( $template, $addons_only, true ) ) {
212 return false;
213 }
214
215 return $load;
216 }
217
218 /**
219 * Get quiz time units options.
220 *
221 * @since 2.6.0
222 *
223 * @return array
224 */
225 public static function quiz_time_units() {
226 $time_units = array(
227 'seconds' => __( 'Seconds', 'tutor' ),
228 'minutes' => __( 'Minutes', 'tutor' ),
229 'hours' => __( 'Hours', 'tutor' ),
230 'days' => __( 'Days', 'tutor' ),
231 'weeks' => __( 'Weeks', 'tutor' ),
232 );
233
234 return apply_filters( 'tutor_quiz_time_units', $time_units );
235 }
236
237 /**
238 * Get quiz default settings.
239 *
240 * @since 3.0.0
241 *
242 * @return array
243 */
244 public static function get_default_quiz_settings() {
245 $settings = array(
246 'time_limit' => array(
247 'time_type' => 'minutes',
248 'time_value' => 0,
249 ),
250 'attempts_allowed' => 10,
251 'answers_reveal_duration' => 5,
252 'auto_start_delay' => 5,
253 'enable_answer_reveal' => '0',
254 'enable_pagination' => '0',
255 'hide_question_number_overview' => 0,
256 'hide_previous_button' => '0',
257 'hide_quiz_time_display' => 0,
258 'limit_attempts_allowed' => '0',
259 'max_questions_for_answer' => 10,
260 'open_ended_answer_characters_limit' => 500,
261 'pass_is_required' => 0,
262 'pagination_type' => 'shape',
263 'passing_grade' => 80,
264 'question_layout_view' => '',
265 'questions_order' => 'rand',
266 'quiz_auto_start' => 0,
267 'short_answer_characters_limit' => 200,
268 );
269
270 return apply_filters( 'tutor_quiz_default_settings', $settings );
271 }
272
273 /**
274 * Normalize quiz settings.
275 *
276 * V4 stores reveal and pagination as explicit flags, but older quizzes may
277 * still contain legacy keys.
278 *
279 * @since 4.0.0
280 *
281 * @param array $settings Quiz settings.
282 *
283 * @return array
284 */
285 public static function normalize_quiz_settings( array $settings ): array {
286 $defaults = self::get_default_quiz_settings();
287 $has_enable_pagination = array_key_exists( 'enable_pagination', $settings );
288 $has_enable_answer_reveal = array_key_exists( 'enable_answer_reveal', $settings );
289 $has_limit_attempts_allowed = array_key_exists( 'limit_attempts_allowed', $settings );
290
291 $settings = wp_parse_args( $settings, $defaults );
292
293 $settings['time_limit'] = wp_parse_args(
294 is_array( $settings['time_limit'] ?? null ) ? $settings['time_limit'] : array(),
295 $defaults['time_limit']
296 );
297
298 $question_layout_view = (string) ( $settings['question_layout_view'] ?? '' );
299 $question_layout_view = '' !== $question_layout_view ? $question_layout_view : 'single_question';
300
301 $supported_pagination_types = array( 'shape', 'radio', 'number' );
302 $pagination_type = $settings['pagination_type'] ?? $settings['question_pagination_style'] ?? 'shape';
303 $pagination_type = in_array( $pagination_type, $supported_pagination_types, true ) ? $pagination_type : 'shape';
304
305 $enable_pagination = $has_enable_pagination
306 ? '1' === (string) $settings['enable_pagination']
307 : 'question_pagination' === $question_layout_view;
308
309 $enable_answer_reveal = $has_enable_answer_reveal
310 ? '1' === (string) $settings['enable_answer_reveal']
311 : self::QUIZ_FEEDBACK_MODE_REVEAL === (string) ( $settings['feedback_mode'] ?? '' );
312 $limit_attempts_allowed = $has_limit_attempts_allowed
313 ? '1' === (string) $settings['limit_attempts_allowed']
314 : self::QUIZ_FEEDBACK_MODE_RETRY === (string) ( $settings['feedback_mode'] ?? '' );
315
316 if ( 'question_pagination' === $question_layout_view ) {
317 $question_layout_view = 'single_question';
318 $enable_pagination = true;
319 }
320
321 if ( 'question_below_each_other' === $question_layout_view ) {
322 $settings['hide_question_number_overview'] = '0';
323 $enable_answer_reveal = false;
324 }
325
326 if ( $enable_pagination ) {
327 $settings['hide_previous_button'] = '0';
328 }
329
330 $settings['question_layout_view'] = $question_layout_view;
331 $settings['enable_pagination'] = $enable_pagination ? '1' : '0';
332 $settings['pagination_type'] = $pagination_type;
333 $settings['enable_answer_reveal'] = $enable_answer_reveal ? '1' : '0';
334 $settings['limit_attempts_allowed'] = $limit_attempts_allowed ? '1' : '0';
335
336 unset( $settings['feedback_mode'], $settings['question_pagination_style'] );
337
338 return apply_filters( 'tutor_quiz_normalized_settings', $settings );
339 }
340
341 /**
342 * Determine if quiz retry is allowed for the current attempt count.
343 *
344 * @since 4.0.0
345 *
346 * @param mixed $limit_attempts_allowed If attempts are limited.
347 * @param int $attempts_allowed Allowed attempts.
348 * @param int $attempted_count Attempted count.
349 *
350 * @return bool
351 */
352 public static function can_retry_quiz( $limit_attempts_allowed, int $attempts_allowed, int $attempted_count ): bool {
353 $limit_attempts_allowed = '1' === (string) $limit_attempts_allowed;
354
355 if ( ! $limit_attempts_allowed ) {
356 return false;
357 }
358
359 return 0 === $attempts_allowed || $attempted_count < $attempts_allowed;
360 }
361
362 /**
363 * Get effective attempt count for quiz display/runtime.
364 *
365 * @since 4.0.0
366 *
367 * @param bool $limit_attempts_allowed If attempts are limited.
368 * @param int $attempts_allowed Allowed attempts.
369 *
370 * @return int
371 */
372 public static function get_effective_attempts_allowed( bool $limit_attempts_allowed, int $attempts_allowed ): int {
373 return $limit_attempts_allowed ? $attempts_allowed : 1;
374 }
375
376 /**
377 * Get question default settings.
378 *
379 * @since 3.0.0
380 *
381 * @param string $type type of question.
382 *
383 * @return array
384 */
385 public static function get_default_question_settings( $type ) {
386 $settings = array(
387 'question_type' => $type,
388 'question_mark' => 1,
389 'answer_required' => 0,
390 'randomize_question' => 0,
391 'show_question_mark' => 0,
392 );
393
394 return apply_filters( 'tutor_question_default_settings', $settings );
395 }
396
397 /**
398 * Get quiz modes
399 *
400 * @since 2.6.0
401 *
402 * @return array
403 */
404 public static function quiz_modes() {
405 $modes = array(
406 array(
407 'key' => 'default',
408 'value' => __( 'Default', 'tutor' ),
409 'description' => __( 'Answers shown after quiz is finished', 'tutor' ),
410 ),
411 array(
412 'key' => 'reveal',
413 'value' => __( 'Reveal Mode', 'tutor' ),
414 'description' => __( 'Show result after the attempt.', 'tutor' ),
415 ),
416 array(
417 'key' => 'retry',
418 'value' => __( 'Retry Mode', 'tutor' ),
419 'description' => __( 'Reattempt quiz any number of times. Define Attempts Allowed below.', 'tutor' ),
420 ),
421 );
422
423 return apply_filters( 'tutor_quiz_modes', $modes );
424 }
425
426 /**
427 * Get quiz modes
428 *
429 * @since 2.6.0
430 *
431 * @return array
432 */
433 public static function quiz_question_layouts() {
434 $layouts = array(
435 '' => __( 'Set question layout view', 'tutor' ),
436 'single_question' => __( 'Single Question', 'tutor' ),
437 'question_pagination' => __( 'Question Pagination', 'tutor' ),
438 'question_below_each_other' => __( 'Question below each other', 'tutor' ),
439 );
440
441 return apply_filters( 'tutor_quiz_layouts', $layouts );
442 }
443
444 /**
445 * Get quiz modes
446 *
447 * @since 2.6.0
448 *
449 * @return array
450 */
451 public static function quiz_question_orders() {
452 $orders = array(
453 'rand' => __( 'Random', 'tutor' ),
454 'sorting' => __( 'Sorting', 'tutor' ),
455 'asc' => __( 'Ascending', 'tutor' ),
456 'desc' => __( 'Descending', 'tutor' ),
457 );
458
459 return apply_filters( 'tutor_quiz_layouts', $orders );
460 }
461
462 /**
463 * Prepare allowed HTML
464 *
465 * @since 1.0.0
466 *
467 * @return void
468 */
469 private function prepare_allowed_html() {
470
471 $allowed = array();
472
473 foreach ( $this->allowed_html as $tag ) {
474 $allowed[ $tag ] = $this->allowed_attributes;
475 }
476
477 $this->allowed_html = $allowed;
478 }
479
480 /**
481 * Instructor feedback ajax request handler
482 *
483 * @since 1.0.0
484 *
485 * @return void
486 */
487 public function tutor_instructor_feedback() {
488 tutor_utils()->checking_nonce();
489
490 // Check if user is privileged.
491 if ( ! User::has_any_role( array( User::ADMIN, User::INSTRUCTOR ) ) ) {
492 wp_send_json_error( tutor_utils()->error_message() );
493 }
494
495 $attempt_details = self::attempt_details( Input::post( 'attempt_id', 0, Input::TYPE_INT ) );
496 $feedback = Input::post( 'feedback', '', Input::TYPE_KSES_POST );
497 $attempt_info = isset( $attempt_details->attempt_info ) ? $attempt_details->attempt_info : false;
498 $course_id = tutor_utils()->avalue_dot( 'course_id', $attempt_details, 0 );
499 $is_instructor = tutor_utils()->is_instructor_of_this_course( get_current_user_id(), $course_id );
500 if ( ! current_user_can( 'manage_options' ) && ! $is_instructor ) {
501 wp_send_json_error( tutor_utils()->error_message() );
502 }
503
504 if ( $attempt_info ) {
505 //phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize
506 $unserialized = unserialize( $attempt_details->attempt_info );
507 if ( is_array( $unserialized ) ) {
508 $unserialized['instructor_feedback'] = $feedback;
509
510 //phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize
511 $update = self::update_attempt_info( $attempt_details->attempt_id, serialize( $unserialized ) );
512 if ( $update ) {
513 do_action( 'tutor_quiz/attempt/submitted/feedback', $attempt_details->attempt_id );
514 wp_send_json_success();
515 } else {
516 wp_send_json_error();
517 }
518 } else {
519 wp_send_json_error( __( 'Invalid quiz info', 'tutor' ) );
520 }
521 }
522 wp_send_json_error();
523 }
524
525 /**
526 * Start Quiz from here...
527 *
528 * @since 1.0.0
529 *
530 * @return void
531 */
532 public function start_the_quiz() {
533 if ( 'tutor_start_quiz' !== Input::post( 'tutor_action' ) ) {
534 return;
535 }
536
537 tutor_utils()->checking_nonce();
538
539 if ( ! is_user_logged_in() ) {
540 wp_die( esc_html__( 'Please sign in to do this operation', 'tutor' ) );
541 }
542
543 $user_id = get_current_user_id();
544 $quiz_id = Input::post( 'quiz_id', 0, Input::TYPE_INT );
545 $course = CourseModel::get_course_by_quiz( $quiz_id );
546
547 if ( ! $course ) {
548 wp_die( esc_html__( 'Invalid course', 'tutor' ) );
549 }
550
551 // Check & die if don't have quiz access.
552 QuizModel::has_quiz_access( $quiz_id, $course->id );
553
554 self::quiz_attempt( $course->ID, $quiz_id, $user_id );
555 wp_safe_redirect( get_permalink( $quiz_id ) );
556 die();
557 }
558
559 /**
560 * Manage quiz attempt
561 *
562 * @since 2.6.1
563 *
564 * @param integer $course_id course id.
565 * @param integer $quiz_id quiz id.
566 * @param integer $user_id user id.
567 * @param string $attempt_status attempt status.
568 *
569 * @return int inserted id|0
570 */
571 public static function quiz_attempt( int $course_id, int $quiz_id, int $user_id, $attempt_status = QuizModel::ATTEMPT_STARTED ) {
572 global $wpdb;
573
574 if ( ! $course_id ) {
575 wp_die( 'There is something went wrong with course, please check if quiz attached with a course' );
576 }
577
578 // Check & die if don't have quiz access.
579 QuizModel::has_quiz_access( $quiz_id, $course_id );
580
581 do_action( 'tutor_quiz/start/before', $quiz_id, $user_id );
582
583 $date = date( 'Y-m-d H:i:s', tutor_time() ); //phpcs:ignore
584
585 $tutor_quiz_option = (array) tutor_utils()->get_quiz_option( $quiz_id );
586
587 $time_limit = tutor_utils()->get_quiz_option( $quiz_id, 'time_limit.time_value' );
588 $time_limit_seconds = 0;
589 $time_type = 'seconds';
590 if ( $time_limit ) {
591 $time_type = tutor_utils()->get_quiz_option( $quiz_id, 'time_limit.time_type' );
592
593 switch ( $time_type ) {
594 case 'seconds':
595 $time_limit_seconds = $time_limit;
596 break;
597 case 'minutes':
598 $time_limit_seconds = $time_limit * 60;
599 break;
600 case 'hours':
601 $time_limit_seconds = $time_limit * 60 * 60;
602 break;
603 case 'days':
604 $time_limit_seconds = $time_limit * 60 * 60 * 24;
605 break;
606 case 'weeks':
607 $time_limit_seconds = $time_limit * 60 * 60 * 24 * 7;
608 break;
609 }
610 }
611
612 $max_question_allowed = tutor_utils()->max_questions_for_take_quiz( $quiz_id );
613 $tutor_quiz_option['time_limit']['time_limit_seconds'] = $time_limit_seconds;
614
615 $attempt_data = array(
616 'course_id' => $course_id,
617 'quiz_id' => $quiz_id,
618 'user_id' => $user_id,
619 'total_questions' => $max_question_allowed,
620 'total_answered_questions' => 0,
621 'attempt_info' => maybe_serialize( $tutor_quiz_option ),
622 'attempt_status' => $attempt_status,
623 'attempt_ip' => tutor_utils()->get_ip(),
624 'attempt_started_at' => $date,
625 );
626
627 $wpdb->insert( $wpdb->prefix . 'tutor_quiz_attempts', $attempt_data );
628 $attempt_id = (int) $wpdb->insert_id;
629
630 if ( $attempt_id ) {
631 do_action( 'tutor_quiz/start/after', $quiz_id, $user_id, $attempt_id );
632 return $attempt_id;
633 } else {
634 return 0;
635 }
636 }
637
638 /**
639 * Answering quiz
640 *
641 * @since 1.0.0
642 *
643 * @return void
644 */
645 public function answering_quiz() {
646 if ( 'tutor_answering_quiz_question' !== Input::post( 'tutor_action' ) ) {
647 return;
648 }
649
650 self::tutor_quiz_attempt_submit();
651
652 wp_safe_redirect( get_the_permalink() );
653 die();
654 }
655
656 /**
657 * Quiz abandon submission handler
658 *
659 * @since 1.9.6
660 *
661 * @return void JSON response
662 */
663 public function tutor_quiz_abandon() {
664 if ( 'tutor_answering_quiz_question' !== Input::post( 'tutor_action' ) ) {
665 return;
666 }
667
668 tutor_utils()->checking_nonce();
669 // submit quiz attempts.
670 if ( self::tutor_quiz_attempt_submit() ) {
671 wp_send_json_success();
672 } else {
673 wp_send_json_error();
674 }
675 }
676
677 /**
678 * Validate quiz attempt
679 *
680 * @since 3.9.13
681 *
682 * @param int $attempt_id attempt id.
683 * @param int $user_id user id.
684 *
685 * @return object|false attempt object if valid otherwise false
686 */
687 private static function validate_attempt( $attempt_id, $user_id ) {
688 $attempt = tutor_utils()->get_attempt( $attempt_id );
689
690 if ( ! $attempt || ! is_object( $attempt ) || (int) $attempt->user_id !== (int) $user_id ) {
691 return false;
692 }
693
694 return $attempt;
695 }
696
697 /**
698 * This is a unified method for handling normal quiz submit or abandon submit
699 * It will handle ajax or normal form submit and can be used with different hooks
700 *
701 * @since 1.9.6
702 *
703 * @return bool true if quiz attempt submit successfully otherwise false
704 */
705 public static function tutor_quiz_attempt_submit() {
706 if ( ! is_user_logged_in() ) {
707 die( 'Please sign in to do this operation' );
708 }
709
710 tutor_utils()->checking_nonce();
711
712 $user_id = get_current_user_id();
713 $attempt_id = Input::post( 'attempt_id', 0, Input::TYPE_INT );
714 $attempt = self::validate_attempt( $attempt_id, $user_id );
715
716 if ( ! $attempt ) {
717 die( 'Operation not allowed, attempt not found or permission denied' );
718 }
719
720 // Check & die if don't have quiz access.
721 $quiz_id = Input::post( 'quiz_id', 0, Input::TYPE_INT );
722 QuizModel::has_quiz_access( $quiz_id );
723
724 if ( QuizModel::ATTEMPT_TIMEOUT === $attempt->attempt_status ) {
725 return false;
726 }
727
728 // Sanitize data by helper method.
729 $attempt_answers = isset( $_POST['attempt'] ) ? tutor_sanitize_data( $_POST['attempt'] ) : false; //phpcs:ignore
730 $attempt_answers = is_array( $attempt_answers ) ? $attempt_answers : array();
731
732 self::manage_attempt_answers( $attempt_answers, $attempt, $attempt_id, $attempt->course_id, $user_id );
733 return true;
734 }
735
736 /**
737 * Manage attempt answers
738 *
739 * Evaluate each attempt answer and update the attempts table & insert in the attempt_answers table.
740 *
741 * @since 2.6.1
742 *
743 * @param array $attempt_answers attempt answers.
744 * @param object $attempt single attempt.
745 * @param int $attempt_id attempt id.
746 * @param int $course_id course id.
747 * @param int $user_id user id.
748 *
749 * @return void
750 */
751 public static function manage_attempt_answers( $attempt_answers, $attempt, $attempt_id, $course_id, $user_id ) {
752 if ( ! is_array( $attempt_answers ) || ! self::validate_attempt( $attempt_id, $user_id ) ) {
753 return;
754 }
755
756 global $wpdb;
757 // Before hook.
758 do_action( 'tutor_quiz/attempt_analysing/before', $attempt_id );
759
760 // Single quiz can have multiple question. So multiple answer should be saved.
761 foreach ( $attempt_answers as $posted_attempt_id => $attempt_answer ) {
762 if ( ! self::validate_attempt( $posted_attempt_id, $user_id ) ) {
763 continue;
764 }
765
766 // Get total marks of all question comes.
767 $question_ids = tutor_utils()->avalue_dot( 'quiz_question_ids', $attempt_answer );
768 $question_ids = array_filter( $question_ids, fn ( $id ) => is_numeric( $id ) && intval( $id ) > 0 );
769
770 // Calculate and set the total marks in attempt table for this question.
771 if ( tutor_utils()->count( $question_ids ) ) {
772 $question_ids_string = QueryHelper::prepare_in_clause( $question_ids );
773
774 // Get total marks of the questions from question table.
775 //phpcs:disable
776 $query = $wpdb->prepare(
777 "SELECT SUM(question_mark)
778 FROM {$wpdb->prefix}tutor_quiz_questions
779 WHERE 1 = %d
780 AND question_id IN({$question_ids_string});
781 ",
782 1
783 );
784 $total_question_marks = $wpdb->get_var( $query );
785 //phpcs:enable
786
787 $total_question_marks = apply_filters( 'tutor_filter_update_before_question_mark', $total_question_marks, $question_ids, $user_id, $attempt_id );
788
789 // Set the the total mark in the attempt table for the question.
790 $wpdb->update(
791 $wpdb->prefix . 'tutor_quiz_attempts',
792 array( 'total_marks' => $total_question_marks ),
793 array( 'attempt_id' => $attempt_id )
794 );
795 }
796
797 $total_marks = 0;
798 $review_required = false;
799 $quiz_answers = tutor_utils()->avalue_dot( 'quiz_question', $attempt_answer );
800
801 if ( tutor_utils()->count( $quiz_answers ) ) {
802
803 foreach ( $quiz_answers as $question_id => $answers ) {
804 $question = QuizModel::get_quiz_question_by_id( $question_id );
805 if ( ! is_object( $question ) ) {
806 continue;
807 }
808 $question_type = $question->question_type;
809
810 $is_answer_was_correct = false;
811 $given_answer = '';
812
813 if ( QuizModel::QUESTION_TYPE_TRUE_FALSE === $question_type || QuizModel::QUESTION_TYPE_SINGLE_CHOICE === $question_type ) {
814 $given_answer = $answers;
815 $is_answer_was_correct = (bool) $wpdb->get_var(
816 $wpdb->prepare(
817 "SELECT is_correct
818 FROM {$wpdb->prefix}tutor_quiz_question_answers
819 WHERE answer_id = %d
820 ",
821 $answers
822 )
823 );
824
825 } elseif ( QuizModel::QUESTION_TYPE_MULTIPLE_CHOICE === $question_type ) {
826
827 $given_answer = (array) ( $answers );
828 $given_answer = array_filter( $given_answer, fn ( $id ) => is_numeric( $id ) && intval( $id ) > 0 );
829
830 $get_original_answers = (array) $wpdb->get_col(
831 $wpdb->prepare(
832 "SELECT
833 answer_id
834 FROM
835 {$wpdb->prefix}tutor_quiz_question_answers
836 WHERE belongs_question_id = %d
837 AND belongs_question_type = %s
838 AND is_correct = 1 ;
839 ",
840 $question->question_id,
841 $question_type
842 )
843 );
844
845 if ( count( array_diff( $get_original_answers, $given_answer ) ) === 0 && count( $get_original_answers ) === count( $given_answer ) ) {
846 $is_answer_was_correct = true;
847 }
848
849 $given_answer = maybe_serialize( $given_answer );
850
851 } elseif ( QuizModel::QUESTION_TYPE_FILL_IN_THE_BLANK === $question_type ) {
852
853 $get_original_answer = $wpdb->get_row(
854 $wpdb->prepare(
855 "SELECT *
856 FROM {$wpdb->prefix}tutor_quiz_question_answers
857 WHERE belongs_question_id = %d
858 AND belongs_question_type = %s ;
859 ",
860 $question->question_id,
861 $question_type
862 )
863 );
864
865 /**
866 * Answers stored in DB
867 */
868 $gap_answer = (array) explode( '|', $get_original_answer->answer_two_gap_match );
869 $gap_answer = maybe_serialize(
870 array_map(
871 function ( $ans ) {
872 return wp_slash( trim( $ans ) );
873 },
874 $gap_answer
875 )
876 );
877
878 /**
879 * Answers from user input
880 */
881 $given_answer = is_array( $answers ) ? array_map( 'sanitize_text_field', $answers ) : array( sanitize_text_field( $answers ) );
882 $given_answer = maybe_serialize( $given_answer );
883
884 /**
885 * Compare answer's by making both case-insensitive.
886 */
887 if ( strtolower( $given_answer ) === strtolower( $gap_answer ) ) {
888 $is_answer_was_correct = true;
889 }
890 } elseif ( QuizModel::QUESTION_TYPE_OPEN_ENDED === $question_type || QuizModel::QUESTION_TYPE_SHORT_ANSWER === $question_type ) {
891 $review_required = true;
892 $given_answer = wp_kses_post( $answers );
893
894 } elseif ( QuizModel::QUESTION_TYPE_ORDERING === $question_type || QuizModel::QUESTION_TYPE_MATCHING === $question_type || QuizModel::QUESTION_TYPE_IMAGE_MATCHING === $question_type ) {
895 $answers = (array) tutor_utils()->avalue_dot( 'answers', $answers );
896
897 $given_answer = (array) array_map( 'sanitize_text_field', $answers );
898 $given_answer = maybe_serialize( $given_answer );
899
900 $get_original_answers = (array) $wpdb->get_col(
901 $wpdb->prepare(
902 "SELECT answer_id
903 FROM {$wpdb->prefix}tutor_quiz_question_answers
904 WHERE belongs_question_id = %d
905 AND belongs_question_type = %s
906 ORDER BY answer_order ASC ;
907 ",
908 $question->question_id,
909 $question_type
910 )
911 );
912
913 $get_original_answers = array_map( 'sanitize_text_field', $get_original_answers );
914
915 if ( maybe_serialize( $get_original_answers ) == $given_answer ) {
916 $is_answer_was_correct = true;
917 }
918 } elseif ( QuizModel::QUESTION_TYPE_IMAGE_ANSWERING === $question_type ) {
919 $image_inputs = tutor_utils()->avalue_dot( 'answer_id', $answers );
920 $image_inputs = (array) array_map( 'sanitize_text_field', $image_inputs );
921 $given_answer = maybe_serialize( $image_inputs );
922
923 $stored_answers = $wpdb->get_results(
924 $wpdb->prepare(
925 "SELECT answer_id, answer_title
926 FROM {$wpdb->prefix}tutor_quiz_question_answers
927 WHERE belongs_question_id = %d
928 AND belongs_question_type = %s
929 ORDER BY answer_order ASC",
930 $question_id,
931 QuizModel::QUESTION_TYPE_IMAGE_ANSWERING
932 )
933 );
934
935 $is_answer_was_correct = true;
936 foreach ( $stored_answers as $stored_answer ) {
937 $user_answer = isset( $image_inputs[ $stored_answer->answer_id ] ) ? trim( wp_unslash( $image_inputs[ $stored_answer->answer_id ] ) ) : '';
938 $correct_answer = trim( wp_unslash( $stored_answer->answer_title ) );
939
940 if ( 0 !== strcasecmp( $user_answer, $correct_answer ) ) {
941 $is_answer_was_correct = false;
942 break;
943 }
944 }
945 } else {
946 $custom_answer_data = array(
947 'given_answer' => $given_answer,
948 'is_answer_was_correct' => $is_answer_was_correct,
949 );
950 $custom_answer_data = apply_filters( 'tutor_quiz_process_custom_question_answer', $custom_answer_data, $question_type, $answers, $question, $question_id, $attempt_id );
951 $given_answer = $custom_answer_data['given_answer'];
952 $is_answer_was_correct = $custom_answer_data['is_answer_was_correct'];
953 }
954
955 $question_mark = $is_answer_was_correct ? $question->question_mark : 0;
956 $total_marks += $question_mark;
957
958 $answers_data = array(
959 'user_id' => $user_id,
960 'quiz_id' => $attempt->quiz_id,
961 'question_id' => $question_id,
962 'quiz_attempt_id' => $attempt_id,
963 'given_answer' => $given_answer,
964 'question_mark' => $question->question_mark,
965 'achieved_mark' => $question_mark,
966 'minus_mark' => 0,
967 'is_correct' => $is_answer_was_correct ? 1 : 0,
968 );
969
970 /**
971 * Check if question_type open ended or short ans the set
972 * is_correct default value null before saving
973 */
974 if ( in_array( $question_type, QuizModel::get_manual_review_types(), true ) ) {
975 $answers_data['is_correct'] = null;
976 $review_required = true;
977 }
978
979 $answers_data = apply_filters( 'tutor_filter_quiz_answer_data', $answers_data, $question_id, $question_type, $user_id, $attempt_id );
980
981 // Filter total marks after grading. Runs after answers_data is built and graded,
982 // so add-ons (e.g. H5P, pin_image, draw_image) can add their achieved marks.
983 $total_marks = apply_filters( 'tutor_filter_quiz_total_marks', $total_marks, $question_id, $question_type, $user_id, $attempt_id, $answers_data );
984
985 $wpdb->insert( $wpdb->prefix . 'tutor_quiz_attempt_answers', $answers_data );
986 }
987 }
988
989 $attempt_info = array(
990 'total_answered_questions' => tutor_utils()->count( $quiz_answers ),
991 'earned_marks' => $total_marks,
992 'attempt_status' => QuizModel::ATTEMPT_ENDED,
993 'attempt_ended_at' => date( 'Y-m-d H:i:s', tutor_time() ), //phpcs:ignore
994 );
995
996 if ( $review_required ) {
997 $attempt_info['attempt_status'] = QuizModel::REVIEW_REQUIRED;
998 }
999
1000 $wpdb->update( $wpdb->tutor_quiz_attempts, $attempt_info, array( 'attempt_id' => $attempt_id ) );
1001
1002 QuizModel::update_attempt_result( $attempt_id );
1003 }
1004
1005 // After hook.
1006 do_action( 'tutor_quiz/attempt_ended', $attempt_id, $course_id, $user_id );
1007 }
1008
1009
1010 /**
1011 * Quiz attempt will be finish here
1012 *
1013 * @since 1.0.0
1014 *
1015 * @return void
1016 */
1017 public function finishing_quiz_attempt() {
1018
1019 if ( Input::post( 'tutor_action' ) !== 'tutor_finish_quiz_attempt' ) {
1020 return;
1021 }
1022 // Checking nonce.
1023 tutor_utils()->checking_nonce();
1024
1025 if ( ! is_user_logged_in() ) {
1026 die( 'Please sign in to do this operation' );
1027 }
1028
1029 global $wpdb;
1030
1031 $quiz_id = Input::post( 'quiz_id', 0, Input::TYPE_INT );
1032 $attempt = tutor_utils()->is_started_quiz( $quiz_id );
1033 if ( ! is_object( $attempt ) ) {
1034 wp_die( esc_html__( 'No active quiz attempt found', 'tutor' ) );
1035 }
1036 $attempt_id = $attempt->attempt_id;
1037
1038 $attempt_info = array(
1039 'total_answered_questions' => 0,
1040 'earned_marks' => 0,
1041 'attempt_status' => QuizModel::ATTEMPT_ENDED,
1042 'attempt_ended_at' => date( 'Y-m-d H:i:s', tutor_time() ), //phpcs:ignore
1043 );
1044
1045 do_action( 'tutor_quiz_before_finish', $attempt_id, $quiz_id, $attempt->user_id );
1046 $wpdb->update( $wpdb->prefix . 'tutor_quiz_attempts', $attempt_info, array( 'attempt_id' => $attempt_id ) );
1047 do_action( 'tutor_quiz_finished', $attempt_id, $quiz_id, $attempt->user_id );
1048
1049 wp_redirect( tutor_utils()->input_old( '_wp_http_referer' ) );
1050 }
1051
1052 /**
1053 * Get quiz total marks.
1054 *
1055 * @since 3.0.0
1056 *
1057 * @param int $quiz_id quiz id.
1058 *
1059 * @return int|float
1060 */
1061 public static function get_quiz_total_marks( $quiz_id ) {
1062 global $wpdb;
1063
1064 $total_marks = $wpdb->get_var(
1065 $wpdb->prepare(
1066 "SELECT SUM(question_mark) total_marks
1067 FROM {$wpdb->prefix}tutor_quiz_questions
1068 WHERE quiz_id=%d",
1069 $quiz_id
1070 )
1071 );
1072
1073 return floatval( $total_marks );
1074 }
1075
1076 /**
1077 * Quiz timeout by ajax
1078 *
1079 * @since 1.0.0
1080 *
1081 * @return void
1082 */
1083 public function tutor_quiz_timeout() {
1084 tutils()->checking_nonce();
1085
1086 global $wpdb;
1087
1088 $quiz_id = Input::post( 'quiz_id', 0, Input::TYPE_INT );
1089 $attempt = tutor_utils()->is_started_quiz( $quiz_id );
1090
1091 if ( is_object( $attempt ) ) {
1092 $attempt_id = $attempt->attempt_id;
1093
1094 $data = array(
1095 'attempt_status' => QuizModel::ATTEMPT_TIMEOUT,
1096 'total_marks' => self::get_quiz_total_marks( $quiz_id ),
1097 'earned_marks' => 0,
1098 'attempt_ended_at' => gmdate( 'Y-m-d H:i:s', tutor_time() ),
1099 );
1100
1101 $wpdb->update( $wpdb->prefix . 'tutor_quiz_attempts', $data, array( 'attempt_id' => $attempt->attempt_id ) );
1102
1103 do_action( 'tutor_quiz_timeout', $attempt_id, $quiz_id, $attempt->user_id );
1104
1105 wp_send_json_success();
1106 }
1107
1108 wp_send_json_error( __( 'Quiz has been timeout already', 'tutor' ) );
1109 }
1110
1111 /**
1112 * Review quiz answer
1113 *
1114 * @since 1.0.0
1115 *
1116 * @return void
1117 */
1118 public function review_quiz_answer() {
1119
1120 tutor_utils()->checking_nonce();
1121
1122 $attempt_id = Input::post( 'attempt_id', 0, Input::TYPE_INT );
1123 $context = Input::post( 'context' );
1124 $attempt_answer_id = Input::post( 'attempt_answer_id', 0, Input::TYPE_INT );
1125 $question_id = Input::post( 'question_id', 0, Input::TYPE_INT );
1126 $mark_as = Input::post( 'mark_as' );
1127
1128 if ( ! tutor_utils()->can_user_manage( 'attempt', $attempt_id ) ) {
1129 wp_send_json_error( array( 'message' => __( 'Access Denied', 'tutor' ) ) );
1130 }
1131
1132 if ( $attempt_answer_id && ! tutor_utils()->can_user_manage( 'attempt_answer', $attempt_answer_id ) ) {
1133 wp_send_json_error( array( 'message' => __( 'Access Denied', 'tutor' ) ) );
1134 }
1135
1136 $attempt_answer = $this->resolve_attempt_answer_for_review( $attempt_id, $attempt_answer_id, $question_id );
1137 $review_data = $attempt_answer ? $this->apply_quiz_answer_review( $attempt_id, $attempt_answer, $mark_as ) : null;
1138
1139 if ( ! $review_data ) {
1140 wp_send_json_error( array( 'message' => __( 'Review update failed', 'tutor' ) ) );
1141 }
1142
1143 QuizModel::update_attempt_result( $attempt_id );
1144
1145 ob_start();
1146 tutor_load_template_from_custom_path(
1147 tutor()->path . '/views/quiz/attempt-details.php',
1148 array(
1149 'attempt_id' => $attempt_id,
1150 'user_id' => $review_data['student_id'],
1151 'context' => $context,
1152 'back_url' => Input::post( 'back_url' ),
1153 )
1154 );
1155 wp_send_json_success( array( 'html' => ob_get_clean() ) );
1156 }
1157
1158 /**
1159 * Review quiz answers in bulk for v4 dashboard flow.
1160 *
1161 * @since 4.0.0
1162 *
1163 * @return void
1164 */
1165 public function review_quiz_answers() {
1166 tutor_utils()->checking_nonce();
1167
1168 $attempt_id = Input::post( 'attempt_id', 0, Input::TYPE_INT );
1169 $review_statuses = Input::post( 'review_statuses', array(), Input::TYPE_ARRAY );
1170
1171 $this->review_quiz_answers_bulk( $attempt_id, $review_statuses );
1172 }
1173
1174 /**
1175 * Review quiz answers in bulk for v4 dashboard flow.
1176 *
1177 * @since 4.0.0
1178 *
1179 * @param int $attempt_id Attempt ID.
1180 * @param array $review_statuses Review statuses keyed by question ID.
1181 *
1182 * @return void
1183 */
1184 private function review_quiz_answers_bulk( int $attempt_id, array $review_statuses ) {
1185 if ( ! tutor_utils()->can_user_manage( 'attempt', $attempt_id ) ) {
1186 $this->response_fail( __( 'Access Denied', 'tutor' ), 403 );
1187 }
1188
1189 $attempt_answers = QuizModel::get_quiz_answers_by_attempt_id( $attempt_id );
1190 $answers_by_question_id = array();
1191
1192 if ( is_array( $attempt_answers ) ) {
1193 foreach ( $attempt_answers as $attempt_answer ) {
1194 $question_id = (int) ( $attempt_answer->question_id ?? 0 );
1195
1196 if ( $question_id > 0 ) {
1197 $answers_by_question_id[ $question_id ] = $attempt_answer;
1198 }
1199 }
1200 }
1201
1202 foreach ( $review_statuses as $question_id => $mark_as ) {
1203 $question_id = (int) $question_id;
1204 $mark_as = (string) $mark_as;
1205
1206 if ( ! in_array( $mark_as, array( 'correct', 'incorrect' ), true ) ) {
1207 continue;
1208 }
1209
1210 $attempt_answer = $answers_by_question_id[ $question_id ] ?? null;
1211
1212 if ( ! $attempt_answer ) {
1213 $attempt_answer = $this->resolve_attempt_answer_for_review( $attempt_id, 0, $question_id );
1214 }
1215
1216 if ( ! $attempt_answer ) {
1217 continue;
1218 }
1219
1220 $this->apply_quiz_answer_review( $attempt_id, $attempt_answer, $mark_as );
1221 }
1222
1223 QuizModel::update_attempt_result( $attempt_id );
1224
1225 $this->response_success( __( 'Review updated successfully', 'tutor' ) );
1226 }
1227
1228 /**
1229 * Get attempt answer record by ID.
1230 *
1231 * @since 4.0.0
1232 *
1233 * @param int $attempt_answer_id Attempt answer ID.
1234 *
1235 * @return object|null
1236 */
1237 public static function get_attempt_answer( int $attempt_answer_id ) {
1238 global $wpdb;
1239
1240 return $wpdb->get_row(
1241 $wpdb->prepare(
1242 "SELECT *
1243 FROM {$wpdb->prefix}tutor_quiz_attempt_answers
1244 WHERE attempt_answer_id = %d",
1245 $attempt_answer_id
1246 )
1247 );
1248 }
1249
1250 /**
1251 * Get attempt answer by attempt and question IDs.
1252 *
1253 * @since 4.0.0
1254 *
1255 * @param int $attempt_id Attempt ID.
1256 * @param int $question_id Question ID.
1257 *
1258 * @return object|null
1259 */
1260 private function get_attempt_answer_by_attempt_and_question( int $attempt_id, int $question_id ) {
1261 if ( $attempt_id <= 0 || $question_id <= 0 ) {
1262 return null;
1263 }
1264
1265 return QueryHelper::get_row(
1266 'tutor_quiz_attempt_answers',
1267 array(
1268 'quiz_attempt_id' => $attempt_id,
1269 'question_id' => $question_id,
1270 ),
1271 'attempt_answer_id',
1272 'ASC'
1273 );
1274 }
1275
1276 /**
1277 * Create a placeholder attempt answer for a skipped question.
1278 *
1279 * @since 4.0.0
1280 *
1281 * @param int $attempt_id Attempt ID.
1282 * @param int $question_id Question ID.
1283 *
1284 * @return object|null
1285 */
1286 private function create_skipped_attempt_answer( int $attempt_id, int $question_id ) {
1287 $attempt = tutor_utils()->get_attempt( $attempt_id );
1288 if ( ! is_object( $attempt ) ) {
1289 return null;
1290 }
1291
1292 $question = QuizModel::get_quiz_question_by_id( $question_id );
1293 if ( ! is_object( $question ) || (int) $question->quiz_id !== (int) $attempt->quiz_id ) {
1294 return null;
1295 }
1296
1297 $attempt_answer = $this->get_attempt_answer_by_attempt_and_question( $attempt_id, $question_id );
1298 if ( $attempt_answer ) {
1299 return $attempt_answer;
1300 }
1301
1302 try {
1303 $inserted_id = QueryHelper::insert(
1304 'tutor_quiz_attempt_answers',
1305 array(
1306 'user_id' => (int) $attempt->user_id,
1307 'quiz_id' => (int) $attempt->quiz_id,
1308 'question_id' => $question_id,
1309 'quiz_attempt_id' => $attempt_id,
1310 'given_answer' => '',
1311 'question_mark' => $question->question_mark,
1312 'achieved_mark' => 0,
1313 'minus_mark' => 0,
1314 'is_correct' => 0,
1315 )
1316 );
1317 } catch ( \Exception $exception ) {
1318 return null;
1319 }
1320
1321 if ( $inserted_id <= 0 ) {
1322 return null;
1323 }
1324
1325 return self::get_attempt_answer( $inserted_id );
1326 }
1327
1328 /**
1329 * Resolve the attempt answer used for instructor review.
1330 *
1331 * @since 4.0.0
1332 *
1333 * @param int $attempt_id Attempt ID.
1334 * @param int $attempt_answer_id Attempt answer ID.
1335 * @param int $question_id Question ID.
1336 *
1337 * @return object|null
1338 */
1339 private function resolve_attempt_answer_for_review( int $attempt_id, int $attempt_answer_id = 0, int $question_id = 0 ) {
1340 $attempt_answer = $attempt_answer_id ? self::get_attempt_answer( $attempt_answer_id ) : null;
1341
1342 if ( $attempt_answer ) {
1343 return $attempt_answer;
1344 }
1345
1346 if ( $question_id <= 0 ) {
1347 return null;
1348 }
1349
1350 $attempt_answer = $this->get_attempt_answer_by_attempt_and_question( $attempt_id, $question_id );
1351
1352 if ( $attempt_answer ) {
1353 return $attempt_answer;
1354 }
1355
1356 return $this->create_skipped_attempt_answer( $attempt_id, $question_id );
1357 }
1358
1359 /**
1360 * Apply quiz answer review update.
1361 *
1362 * @since 4.0.0
1363 *
1364 * @param int $attempt_id Attempt ID.
1365 * @param object $attempt_answer Attempt answer row.
1366 * @param string $mark_as Review status.
1367 *
1368 * @return array|null
1369 */
1370 private function apply_quiz_answer_review( int $attempt_id, $attempt_answer, string $mark_as ) {
1371 global $wpdb;
1372
1373 if ( ! $attempt_answer || ! in_array( $mark_as, array( 'correct', 'incorrect' ), true ) ) {
1374 return null;
1375 }
1376
1377 $attempt = tutor_utils()->get_attempt( $attempt_id );
1378
1379 if ( ! is_object( $attempt ) ) {
1380 return null;
1381 }
1382
1383 $attempt_answer_id = (int) $attempt_answer->attempt_answer_id;
1384 $question = QuizModel::get_quiz_question_by_id( $attempt_answer->question_id );
1385 if ( ! is_object( $question ) ) {
1386 return null;
1387 }
1388 $course_id = (int) $attempt->course_id;
1389 $student_id = (int) $attempt->user_id;
1390 $previous_ans = $attempt_answer->is_correct;
1391
1392 do_action( 'tutor_quiz_review_answer_before', $attempt_answer_id, $attempt_id, $mark_as );
1393
1394 $mark_as = apply_filters( 'tutor_quiz_review_mark_as', $mark_as, $attempt_answer_id, $attempt_id, $question );
1395
1396 if ( 'correct' === $mark_as ) {
1397 $attempt_update_data = array();
1398 $answer_update_data = array(
1399 'achieved_mark' => $attempt_answer->question_mark,
1400 'is_correct' => 1,
1401 );
1402
1403 $wpdb->update( $wpdb->prefix . 'tutor_quiz_attempt_answers', $answer_update_data, array( 'attempt_answer_id' => $attempt_answer_id ) );
1404
1405 if ( 0 == $previous_ans || null == $previous_ans ) {
1406 $attempt_update_data = array(
1407 'earned_marks' => $attempt->earned_marks + $attempt_answer->question_mark,
1408 'is_manually_reviewed' => 1,
1409 'manually_reviewed_at' => date( 'Y-m-d H:i:s', tutor_time() ), //phpcs:ignore
1410 );
1411 }
1412
1413 if ( 'open_ended' === $question->question_type || 'short_answer' === $question->question_type ) {
1414 $attempt_update_data['attempt_status'] = QuizModel::ATTEMPT_ENDED;
1415 }
1416
1417 if ( ! empty( $attempt_update_data ) ) {
1418 $wpdb->update( $wpdb->tutor_quiz_attempts, $attempt_update_data, array( 'attempt_id' => $attempt_id ) );
1419 }
1420 } elseif ( 'incorrect' === $mark_as ) {
1421 $attempt_update_data = array();
1422 $answer_update_data = array(
1423 'achieved_mark' => '0.00',
1424 'is_correct' => 0,
1425 );
1426
1427 $wpdb->update( $wpdb->prefix . 'tutor_quiz_attempt_answers', $answer_update_data, array( 'attempt_answer_id' => $attempt_answer_id ) );
1428
1429 if ( 1 == $previous_ans ) {
1430 $attempt_update_data = array(
1431 'earned_marks' => $attempt->earned_marks - $attempt_answer->question_mark,
1432 'is_manually_reviewed' => 1,
1433 'manually_reviewed_at' => date( 'Y-m-d H:i:s', tutor_time() ), //phpcs:ignore
1434 );
1435 }
1436
1437 if ( 'open_ended' === $question->question_type || 'short_answer' === $question->question_type ) {
1438 $attempt_update_data['attempt_status'] = QuizModel::ATTEMPT_ENDED;
1439 }
1440
1441 if ( ! empty( $attempt_update_data ) ) {
1442 $wpdb->update( $wpdb->tutor_quiz_attempts, $attempt_update_data, array( 'attempt_id' => $attempt_id ) );
1443 }
1444 }
1445
1446 do_action( 'tutor_quiz_review_answer_after', $attempt_answer_id, $attempt_id, $mark_as );
1447 do_action( 'tutor_quiz/answer/review/after', $attempt_answer_id, $course_id, $student_id );
1448
1449 return array(
1450 'course_id' => $course_id,
1451 'student_id' => $student_id,
1452 );
1453 }
1454
1455 /**
1456 * Do auto course complete after review a quiz attempt.
1457 *
1458 * @since 2.4.0
1459 *
1460 * @param int $attempt_answer_id attempt answer id.
1461 * @param int $course_id course id.
1462 * @param int $user_id student id.
1463 *
1464 * @return void
1465 */
1466 public function do_auto_course_complete( $attempt_answer_id, $course_id, $user_id ) {
1467 if ( CourseModel::can_autocomplete_course( $course_id, $user_id ) ) {
1468 CourseModel::mark_course_as_completed( $course_id, $user_id );
1469 Course::set_review_popup_data( $user_id, $course_id );
1470 }
1471 }
1472
1473 /**
1474 * Get a quiz details by id
1475 *
1476 * @return void
1477 */
1478 public function ajax_quiz_details() {
1479 tutor_utils()->check_nonce();
1480
1481 $quiz_id = Input::post( 'quiz_id', 0, Input::TYPE_INT );
1482 if ( ! tutor_utils()->can_user_manage( 'quiz', $quiz_id ) ) {
1483 $this->json_response(
1484 tutor_utils()->error_message(),
1485 null,
1486 HttpHelper::STATUS_FORBIDDEN
1487 );
1488 }
1489
1490 $data = QuizModel::get_quiz_details( $quiz_id );
1491
1492 $data = apply_filters( 'tutor_quiz_details_response', $data, $quiz_id );
1493
1494 $this->json_response(
1495 __( 'Quiz data fetched successfully', 'tutor' ),
1496 $data
1497 );
1498 }
1499
1500 /**
1501 * Delete quiz by id
1502 *
1503 * @since 1.0.0
1504 * @since 3.0.0 refactor and response change.
1505 *
1506 * @return void
1507 */
1508 public function ajax_quiz_delete() {
1509 if ( ! tutor_utils()->is_nonce_verified() ) {
1510 $this->json_response( tutor_utils()->error_message( 'nonce' ), null, HttpHelper::STATUS_BAD_REQUEST );
1511 }
1512
1513 global $wpdb;
1514
1515 $quiz_id = Input::post( 'quiz_id', 0, Input::TYPE_INT );
1516 if ( ! tutor_utils()->can_user_manage( 'quiz', $quiz_id ) ) {
1517 $this->json_response(
1518 tutor_utils()->error_message(),
1519 null,
1520 HttpHelper::STATUS_FORBIDDEN
1521 );
1522 }
1523
1524 $post = get_post( $quiz_id );
1525 if ( 'tutor_quiz' !== $post->post_type ) {
1526 $this->json_response(
1527 __( 'Invalid quiz', 'tutor' ),
1528 null,
1529 HttpHelper::STATUS_BAD_REQUEST
1530 );
1531 }
1532
1533 do_action( 'tutor_delete_quiz_before', $quiz_id );
1534
1535 // Collect file paths from all question types that store files before deleting rows (files deleted after DB for safety).
1536 $attempts_for_quiz = QueryHelper::get_all( 'tutor_quiz_attempts', array( 'quiz_id' => $quiz_id ), 'attempt_id', -1 );
1537 $attempt_file_paths = array();
1538 if ( ! empty( $attempts_for_quiz ) ) {
1539 $attempt_ids = array_map(
1540 function ( $row ) {
1541 return (int) $row->attempt_id;
1542 },
1543 $attempts_for_quiz
1544 );
1545 $attempt_file_paths = apply_filters( 'tutor_quiz/attempt_file_paths_for_deletion', array(), $attempt_ids );
1546 $attempt_file_paths = is_array( $attempt_file_paths ) ? array_values( array_filter( array_unique( $attempt_file_paths ) ) ) : array();
1547 }
1548
1549 $wpdb->delete( $wpdb->prefix . 'tutor_quiz_attempts', array( 'quiz_id' => $quiz_id ) );
1550 $wpdb->delete( $wpdb->prefix . 'tutor_quiz_attempt_answers', array( 'quiz_id' => $quiz_id ) );
1551
1552 QuizModel::delete_files_by_paths( $attempt_file_paths );
1553
1554 // Collect instructor file paths before deleting question data (e.g. draw_image / pin_image masks).
1555 /**
1556 * Filter to get file paths for quiz deletion.
1557 * Pro and other add-ons register their question types via this filter.
1558 *
1559 * @param string[] $file_paths Paths collected so far.
1560 * @param int $quiz_id Quiz post ID.
1561 */
1562 $quiz_file_paths = apply_filters( 'tutor_quiz_quiz_file_paths_for_deletion', array(), $quiz_id );
1563
1564 $questions_ids = $wpdb->get_col( $wpdb->prepare( "SELECT question_id FROM {$wpdb->prefix}tutor_quiz_questions WHERE quiz_id = %d ", $quiz_id ) );
1565
1566 if ( is_array( $questions_ids ) && count( $questions_ids ) ) {
1567 $in_question_ids = QueryHelper::prepare_in_clause( $questions_ids );
1568 //phpcs:disable
1569 $wpdb->query(
1570 "DELETE
1571 FROM {$wpdb->prefix}tutor_quiz_question_answers
1572 WHERE belongs_question_id IN({$in_question_ids})
1573 "
1574 );
1575 //phpcs:enable
1576 }
1577
1578 $wpdb->delete( $wpdb->prefix . 'tutor_quiz_questions', array( 'quiz_id' => $quiz_id ) );
1579
1580 QuizModel::delete_files_by_paths( $quiz_file_paths );
1581
1582 wp_delete_post( $quiz_id, true );
1583
1584 do_action( 'tutor_delete_quiz_after', $quiz_id );
1585
1586 $this->json_response(
1587 __( 'Quiz deleted successfully', 'tutor' ),
1588 $quiz_id
1589 );
1590 }
1591
1592 /**
1593 * Get answers by quiz id
1594 *
1595 * @since 1.0.0
1596 *
1597 * @param int $question_id question id.
1598 * @param mixed $question_type type of question.
1599 * @param boolean $is_correct only correct answers or not.
1600 *
1601 * @return array|object|null
1602 */
1603 private function get_answers_by_q_id( $question_id, $question_type, $is_correct = false ) {
1604 global $wpdb;
1605
1606 $correct_clause = $is_correct ? ' AND is_correct=1 ' : '';
1607 //phpcs:disable
1608 return $wpdb->get_results(
1609 $wpdb->prepare(
1610 "SELECT * FROM {$wpdb->prefix}tutor_quiz_question_answers
1611 WHERE belongs_question_id = %d
1612 AND belongs_question_type = %s
1613 {$correct_clause}
1614 ORDER BY answer_order ASC;
1615 ",
1616 $question_id,
1617 esc_sql( $question_type )
1618 )
1619 );
1620 //phpcs:enable
1621 }
1622
1623 /**
1624 * Rendering quiz for frontend
1625 *
1626 * @since 1.0.0
1627 *
1628 * @return void send wp_json response
1629 */
1630 public function tutor_render_quiz_content() {
1631
1632 tutor_utils()->checking_nonce();
1633
1634 $quiz_id = Input::post( 'quiz_id', 0, Input::TYPE_INT );
1635
1636 if ( ! tutor_utils()->has_enrolled_content_access( 'quiz', $quiz_id ) ) {
1637 wp_send_json_error( array( 'message' => __( 'Access Denied.', 'tutor' ) ) );
1638 }
1639
1640 ob_start();
1641 global $post;
1642
1643 $post = get_post( $quiz_id ); //phpcs:ignore
1644 setup_postdata( $post );
1645
1646 single_quiz_contents();
1647 wp_reset_postdata();
1648
1649 $html = ob_get_clean();
1650 wp_send_json_success( array( 'html' => $html ) );
1651 }
1652
1653 /**
1654 * Get attempt details
1655 *
1656 * @since 1.0.0
1657 *
1658 * @param int $attempt_id required attempt id to get details.
1659 *
1660 * @return mixed object on success, null on failure
1661 */
1662 public static function attempt_details( int $attempt_id ) {
1663 global $wpdb;
1664 $attempt_details = $wpdb->get_row(
1665 $wpdb->prepare(
1666 "SELECT *
1667 FROM {$wpdb->prefix}tutor_quiz_attempts
1668 WHERE attempt_id = %d
1669 ",
1670 $attempt_id
1671 )
1672 );
1673 return $attempt_details;
1674 }
1675
1676 /**
1677 * Update quiz attempt info
1678 *
1679 * @since 1.0.0
1680 *
1681 * @param int $attempt_id attempt id.
1682 * @param mixed $attempt_info serialize data.
1683 *
1684 * @return bool
1685 */
1686 public static function update_attempt_info( int $attempt_id, $attempt_info ) {
1687 global $wpdb;
1688 $table = $wpdb->prefix . 'tutor_quiz_attempts';
1689 $update_info = $wpdb->update(
1690 $table,
1691 array( 'attempt_info' => $attempt_info ),
1692 array( 'attempt_id' => $attempt_id )
1693 );
1694 return $update_info ? true : false;
1695 }
1696
1697 /**
1698 * Attempt delete ajax request handler
1699 *
1700 * @since 2.1.0
1701 *
1702 * @return void wp_json response
1703 */
1704 public function attempt_delete() {
1705 tutor_utils()->checking_nonce();
1706
1707 $attempt_id = Input::post( 'id', 0, Input::TYPE_INT );
1708 $attempt = tutor_utils()->get_attempt( $attempt_id );
1709 if ( ! is_object( $attempt ) ) {
1710 wp_send_json_error( __( 'Invalid attempt ID', 'tutor' ) );
1711 }
1712
1713 $user_id = get_current_user_id();
1714 $course_id = $attempt->course_id;
1715
1716 if ( tutor_utils()->can_user_edit_course( $user_id, $course_id ) ) {
1717 QuizModel::delete_quiz_attempt( $attempt_id );
1718 wp_send_json_success( __( 'Attempt deleted successfully!', 'tutor' ) );
1719 } else {
1720 wp_send_json_error( tutor_utils()->error_message() );
1721 }
1722 }
1723
1724 /**
1725 * Get all quiz attempts for a user in a specific course.
1726 *
1727 * @since 3.8.1
1728 *
1729 * @param int $course_id The ID of the course.
1730 *
1731 * @return array Returns an array of quiz attempt objects with their answers, or an empty array on error.
1732 */
1733 public function get_quiz_attempts_and_answers_by_course_id( int $course_id ): array {
1734 global $wpdb;
1735
1736 $results = QueryHelper::get_all( $wpdb->tutor_quiz_attempts, array( 'course_id' => $course_id ), 'course_id', -1 );
1737
1738 if ( empty( $results ) ) {
1739 return array();
1740 }
1741
1742 return array_map(
1743 function ( $item ) {
1744 $item->quiz_attempt_answers = $this->get_quiz_attempt_answers_by_attempt_id( $item->attempt_id );
1745 return $item;
1746 },
1747 $results
1748 );
1749 }
1750
1751 /**
1752 * Get all quiz attempt answers for a specific quiz attempt.
1753 *
1754 * @since 3.8.1
1755 *
1756 * @param int $attempt_id The ID of the quiz attempt.
1757 *
1758 * @return array Returns an array of quiz attempt answers objects, or an empty array on error.
1759 */
1760 private function get_quiz_attempt_answers_by_attempt_id( int $attempt_id ): array {
1761 global $wpdb;
1762
1763 $results = QueryHelper::get_all( $wpdb->tutor_quiz_attempt_answers, array( 'quiz_attempt_id' => $attempt_id ), 'quiz_attempt_id', -1 );
1764
1765 if ( empty( $results ) ) {
1766 return array();
1767 }
1768
1769 return $results;
1770 }
1771
1772 /**
1773 * Render quiz title as nav item to show on the learning area
1774 *
1775 * @since 4.0.0
1776 *
1777 * @param WP_Post $quiz Quiz post object.
1778 * @param bool $can_access Can user access this content.
1779 *
1780 * @return void
1781 */
1782 public function render_nav_item( WP_Post $quiz, bool $can_access ): void {
1783 tutor_load_template(
1784 'learning-area.quiz.nav-item',
1785 array(
1786 'quiz' => $quiz,
1787 'can_access' => $can_access,
1788 )
1789 );
1790 }
1791
1792 /**
1793 * Render content for the a single quiz
1794 *
1795 * @since 4.0.0
1796 *
1797 * @param WP_Post $quiz Quiz post object.
1798 *
1799 * @return void
1800 */
1801 public function render_single_content( WP_Post $quiz ): void {
1802 tutor_load_template(
1803 'learning-area.quiz.content',
1804 array(
1805 'quiz' => $quiz,
1806 )
1807 );
1808 }
1809
1810 /**
1811 * Render quiz summary
1812 *
1813 * @since 4.0.0
1814 *
1815 * @param int $total_questions Total questions.
1816 * @param string $quiz_item_readable Readable time.
1817 * @param int $total_marks Total Marks.
1818 * @param string $passing_grade Passing grade.
1819 * @param string $earned_marks Earned marks.
1820 * @param string $attempts_allowed Total Attempts allowed.
1821 *
1822 * @return void
1823 */
1824 public static function render_quiz_summary( $total_questions, $quiz_item_readable, $total_marks, $passing_grade, $earned_marks, $attempts_allowed ) {
1825 $quiz_summary = array(
1826 array(
1827 'columns' => array(
1828 array(
1829 'content' => '<div class="tutor-flex tutor-gap-3 tutor-items-center">
1830 ' . SvgIcon::make()->name( Icon::QUESTION_CIRCLE )->size( 20 )->get() . __( 'Questions', 'tutor' ) . '
1831 </div>',
1832 ),
1833 array( 'content' => $total_questions ),
1834 ),
1835 ),
1836 );
1837
1838 if ( ! empty( $quiz_item_readable ) ) {
1839 $quiz_summary[] = array(
1840 'columns' => array(
1841 array(
1842 'content' => '<div class="tutor-flex tutor-gap-3 tutor-items-center">
1843 ' . SvgIcon::make()->name( Icon::TIME )->size( 20 )->get() . __( 'Quiz Time', 'tutor' ) . '
1844 </div>',
1845 ),
1846 array( 'content' => $quiz_item_readable ),
1847 ),
1848 );
1849 }
1850
1851 $quiz_summary[] = array(
1852 'columns' => array(
1853 array(
1854 'content' => '<div class="tutor-flex tutor-gap-3 tutor-items-center">
1855 ' . SvgIcon::make()->name( Icon::PRIME_CHECK_CIRCLE )->size( 20 )->get() . __( 'Total Marks', 'tutor' ) . '
1856 </div>',
1857 ),
1858 array( 'content' => $total_marks ),
1859 ),
1860 );
1861
1862 $quiz_summary[] = array(
1863 'columns' => array(
1864 array(
1865 'content' => '<div class="tutor-flex tutor-gap-3 tutor-items-center">
1866 ' . SvgIcon::make()->name( Icon::PASSED )->size( 20 )->get() . __( 'Passing Grade', 'tutor' ) . '
1867 </div>',
1868 ),
1869 array( 'content' => $passing_grade . '%' ),
1870 ),
1871 );
1872
1873 if ( $earned_marks ) {
1874 $quiz_summary[] = array(
1875 'columns' => array(
1876 array(
1877 'content' => '<div class="tutor-flex tutor-gap-3 tutor-items-center">
1878 ' . SvgIcon::make()->name( Icon::STAR )->size( 20 )->get() . __( 'Earned Grade', 'tutor' ) . '
1879 </div>',
1880 ),
1881 array( 'content' => $earned_marks . '%' ),
1882 ),
1883 );
1884 }
1885
1886 if ( 1 !== $attempts_allowed ) {
1887 $quiz_summary[] = array(
1888 'columns' => array(
1889 array(
1890 'content' => '<div class="tutor-flex tutor-gap-3 tutor-items-center">
1891 ' . SvgIcon::make()->name( Icon::TARGET )->size( 20 )->get() . __( 'Total Attempts', 'tutor' ) . '
1892 </div>',
1893 ),
1894 array( 'content' => 0 === $attempts_allowed ? __( 'No Limit', 'tutor' ) : $attempts_allowed ),
1895 ),
1896 );
1897 }
1898
1899 Table::make()->contents( $quiz_summary )->render();
1900 }
1901
1902 /**
1903 * Render quiz attempts
1904 *
1905 * @since 4.0.0
1906 *
1907 * @param int $quiz_id Quiz ID.
1908 *
1909 * @return void
1910 */
1911 public static function render_quiz_attempts( $quiz_id ) {
1912 $quiz_id = tutor_utils()->get_post_id( $quiz_id );
1913 if ( ! $quiz_id ) {
1914 return;
1915 }
1916
1917 $user_id = get_current_user_id();
1918 $quiz_model = new QuizModel();
1919 $attempts = $quiz_model->quiz_attempts( $quiz_id, $user_id );
1920
1921 if ( empty( $attempts ) ) {
1922 return;
1923 }
1924
1925 $attempts_list = QuizModel::format_quiz_attempts( $attempts, '' );
1926
1927 if ( empty( $attempts_list ) ) {
1928 return;
1929 }
1930
1931 $attempts_count = count( $attempts_list );
1932 $quiz_attempt_obj = new Quiz_Attempts_List( false );
1933 ?>
1934 <div class="tutor-quiz-attempts tutor-border tutor-rounded-2xl">
1935 <div class="tutor-quiz-attempts-header">
1936 <div class="tutor-quiz-attempts-header-item">
1937 <?php esc_html_e( 'Attempts', 'tutor' ); ?>
1938 </div>
1939 <div class="tutor-quiz-attempts-header-item">
1940 <?php esc_html_e( 'Marks', 'tutor' ); ?>
1941 </div>
1942 <div class="tutor-quiz-attempts-header-item">
1943 <?php esc_html_e( 'Time', 'tutor' ); ?>
1944 </div>
1945 <div class="tutor-quiz-attempts-header-item">
1946 <?php esc_html_e( 'Result', 'tutor' ); ?>
1947 </div>
1948 </div>
1949
1950 <div class="tutor-quiz-attempts-list">
1951 <?php
1952 foreach ( $attempts_list as $index => $attempt ) {
1953 $attempt_number = $attempts_count - $index;
1954 ?>
1955 <div class="tutor-quiz-attempts-item-wrapper">
1956 <?php
1957 tutor_load_template(
1958 'shared.components.student-quiz-attempt-row',
1959 array(
1960 'attempt' => $attempt,
1961 'attempt_number' => $attempt_number,
1962 'quiz_id' => $attempt['quiz_id'] ?? 0,
1963 'course_id' => $attempt['course_id'] ?? 0,
1964 'quiz_attempt_obj' => $quiz_attempt_obj,
1965 'is_previous' => false,
1966 'is_learning_area' => true,
1967 )
1968 );
1969 ?>
1970
1971 <div class="tutor-quiz-item-actions">
1972 <?php $quiz_attempt_obj->render_details_button( $attempt, true ); ?>
1973 </div>
1974 </div>
1975 <?php
1976 }
1977 ?>
1978 </div>
1979 </div>
1980 <?php
1981 }
1982
1983 /**
1984 * Render quiz actions
1985 *
1986 * @since 4.0.0
1987 *
1988 * @param int $quiz_id Quiz ID.
1989 *
1990 * @return void
1991 */
1992 public static function render_quiz_actions( $quiz_id ) {
1993 $quiz_id = tutor_utils()->get_post_id( $quiz_id );
1994 if ( ! $quiz_id ) {
1995 return;
1996 }
1997
1998 $quiz_settings = tutor_utils()->get_quiz_option( $quiz_id );
1999 $user_id = get_current_user_id();
2000 $quiz_model = new QuizModel();
2001 $attempts = $quiz_model->quiz_attempts( $quiz_id, $user_id );
2002 $attempted_count = (int) tutor_utils()->count( $attempts );
2003 $limit_attempts_allowed = '1' === (string) ( $quiz_settings['limit_attempts_allowed'] ?? '0' );
2004 $attempts_allowed = (int) ( $quiz_settings['attempts_allowed'] ?? 0 );
2005 $can_start_quiz = 0 === $attempted_count || self::can_retry_quiz( $limit_attempts_allowed, $attempts_allowed, $attempted_count );
2006 $quiz_auto_start = $quiz_settings['quiz_auto_start'] ?? 0;
2007 $auto_start_delay = (int) ( $quiz_settings['auto_start_delay'] ?? 5 );
2008 $should_auto_start = 1 === (int) $quiz_auto_start && 0 === (int) $attempted_count;
2009
2010 global $tutor_current_post, $tutor_course_id;
2011 $current_content_id = $tutor_current_post ? $tutor_current_post->ID : $quiz_id;
2012 $course_id = $tutor_course_id ? $tutor_course_id : tutor_utils()->get_course_id_by_subcontent( $current_content_id );
2013 $contents = tutor_utils()->get_course_prev_next_contents_by_id( $current_content_id );
2014 $next_id = is_object( $contents ) ? $contents->next_id : 0;
2015 $skip_url = get_the_permalink( $next_id ? $next_id : $course_id );
2016 $skip_modal_id = 'tutor-quiz-skip-to-next';
2017 $auto_start_modal_id = 'tutor-quiz-autostart-modal';
2018 $retry_modal_id = 'tutor-quiz-retry-modal-' . $quiz_id;
2019 $show_retry_modal = $attempted_count > 0;
2020
2021 $can_skip_quiz = ( 0 === $attempted_count );
2022 $show_continue = ( $attempted_count > 0 && $next_id );
2023 $has_any_action = $can_skip_quiz || $show_continue || $can_start_quiz;
2024
2025 if ( ! $has_any_action ) {
2026 return;
2027 }
2028 ?>
2029 <div class="tutor-learning-area-footer">
2030 <?php
2031 if ( $can_skip_quiz ) {
2032 Button::make()
2033 ->label( __( 'Skip Quiz', 'tutor' ) )
2034 ->variant( Variant::GHOST )
2035 ->attr( '@click', "TutorCore.modal.showModal('$skip_modal_id')" )
2036 ->render();
2037
2038 $skip_modal_confirm_button = Button::make()
2039 ->tag( 'a' )
2040 ->label( __( 'Yes, Skip This', 'tutor' ) )
2041 ->variant( Variant::DESTRUCTIVE )
2042 ->size( Size::SMALL )
2043 ->attr( 'href', esc_url( $skip_url ) )
2044 ->get();
2045
2046 $skip_modal_cancel_button = Button::make()
2047 ->label( __( 'Cancel', 'tutor' ) )
2048 ->variant( Variant::SECONDARY )
2049 ->size( Size::SMALL )
2050 ->attr( '@click', "TutorCore.modal.closeModal('$skip_modal_id')" )
2051 ->get();
2052
2053 ConfirmationModal::make()
2054 ->id( $skip_modal_id )
2055 ->icon( tutor_utils()->get_themed_svg( 'images/illustrations/warning.svg' ), 80, 80, ConfirmationModal::ICON_TYPE_HTML )
2056 ->title( __( 'Do You Want to Skip This Quiz?', 'tutor' ) )
2057 ->message( __( 'Are you sure you want to skip this quiz? Please confirm your choice.', 'tutor' ) )
2058 ->confirm_button( $skip_modal_confirm_button )
2059 ->cancel_button( $skip_modal_cancel_button )
2060 ->render();
2061 }
2062 ?>
2063
2064 <?php if ( $can_start_quiz ) : ?>
2065 <?php if ( $show_retry_modal ) : ?>
2066 <?php
2067 Button::make()
2068 ->label( __( 'Retry Quiz', 'tutor' ) )
2069 ->variant( $show_continue ? Variant::GHOST : Variant::PRIMARY )
2070 ->attr( 'type', 'button' )
2071 ->attr(
2072 '@click',
2073 sprintf(
2074 'TutorCore.modal.showModal("%s", { data: %s });',
2075 $retry_modal_id,
2076 wp_json_encode(
2077 array(
2078 'quizID' => $quiz_id,
2079 'redirectURL' => get_post_permalink( $quiz_id ),
2080 )
2081 )
2082 )
2083 )
2084 ->render();
2085 ?>
2086 <?php else : ?>
2087 <form
2088 x-data="tutorQuizAutoStart({
2089 quizID: <?php echo esc_attr( $quiz_id ); ?>,
2090 autoStart: <?php echo $should_auto_start ? 'true' : 'false'; ?>,
2091 autoStartModalId: '<?php echo esc_attr( $auto_start_modal_id ); ?>',
2092 countdownSeconds: <?php echo esc_attr( $auto_start_delay ); ?>,
2093 })"
2094 @submit.prevent="handleStartQuiz()"
2095 >
2096 <?php
2097 Button::make()
2098 ->label( __( 'Start Quiz', 'tutor' ) )
2099 ->attr( 'x-bind:disabled', 'startQuizMutation?.isPending' )
2100 ->attr( ':class', "{ 'tutor-btn-loading': startQuizMutation?.isPending }" )
2101 ->render();
2102 ?>
2103 </form>
2104 <?php endif; ?>
2105 <?php endif; ?>
2106
2107 <?php
2108 if ( $show_continue ) {
2109 Button::make()
2110 ->tag( 'a' )
2111 ->label( __( 'Continue Lesson', 'tutor' ) )
2112 ->attr( 'href', esc_url( get_the_permalink( $next_id ) ) )
2113 ->render();
2114 }
2115 ?>
2116 </div>
2117
2118 <?php if ( $show_retry_modal ) : ?>
2119 <div x-data="tutorQuizRetryAttempt()">
2120 <?php
2121 ConfirmationModal::make()
2122 ->id( $retry_modal_id )
2123 ->title( __( 'Retry Quiz?', 'tutor' ) )
2124 ->icon( tutor_utils()->get_themed_svg( 'images/illustrations/quiz-retry.svg' ), 80, 80, ConfirmationModal::ICON_TYPE_HTML )
2125 ->message( __( 'Retrying this quiz will reset your current attempt. Your answers and score from this attempt will be lost.', 'tutor' ) )
2126 ->confirm_handler( 'retryMutation?.mutate({...payload?.data})' )
2127 ->confirm_text( __( 'Retry Quiz', 'tutor' ) )
2128 ->mutation_state( 'retryMutation' )
2129 ->render();
2130 ?>
2131 </div>
2132 <?php endif; ?>
2133
2134 <?php
2135 Modal::make()
2136 ->id( $auto_start_modal_id )
2137 ->closeable( false )
2138 ->width( '268px' )
2139 ->template(
2140 tutor()->path . 'templates/learning-area/quiz/modals/auto-start.php',
2141 array(
2142 'countdown_seconds' => $auto_start_delay,
2143 'modal_id' => $auto_start_modal_id,
2144 )
2145 )
2146 ->render();
2147 ?>
2148 <?php
2149 }
2150
2151 /**
2152 * Render individual question template
2153 *
2154 * @since 4.0.0
2155 *
2156 * @param object $question Question data.
2157 * @param int $index Question index.
2158 *
2159 * @return void
2160 */
2161 public static function render_question( $question, $index = 0 ) {
2162 $question_settings = maybe_unserialize( $question->question_settings );
2163 $question_type = $question->question_type;
2164 $rand_choice = ! empty( $question_settings['randomize_question'] )
2165 && '1' === $question_settings['randomize_question'];
2166
2167 // Normalize question type + settings.
2168 switch ( $question->question_type ) {
2169 case 'short_answer':
2170 $question->question_type = 'open_ended';
2171 break;
2172
2173 case 'single_choice':
2174 $question->question_type = 'multiple_choice';
2175 $question_settings['has_multiple_correct_answer'] = '0';
2176 break;
2177
2178 case 'image_matching':
2179 $question->question_type = 'matching';
2180 $question_settings['is_image_matching'] = '1';
2181 break;
2182 }
2183
2184 $template = str_replace( '_', '-', $question->question_type );
2185 $answers = self::prepare_question_answers( (int) $question->question_id, $question_type, $rand_choice );
2186
2187 $question->index = $index;
2188 $question->question_settings = $question_settings;
2189 $question->question_answers = $answers['question_answers'];
2190 $question->question_randomized_answers = $answers['question_randomized_answers'];
2191
2192 tutor_load_template(
2193 'learning-area.quiz.question',
2194 array(
2195 'question' => $question,
2196 'question_settings' => $question_settings,
2197 'question_type' => $template,
2198 )
2199 );
2200 }
2201
2202 /**
2203 * Get question answers prepared for render.
2204 *
2205 * @since 4.0.0
2206 *
2207 * @param int $question_id Question ID.
2208 * @param string $question_type Original question type.
2209 * @param bool $rand_choice Whether randomized choices are enabled.
2210 *
2211 * @return array{question_answers: array, question_randomized_answers: array}
2212 */
2213 private static function prepare_question_answers( int $question_id, string $question_type, bool $rand_choice ): array {
2214 $question_answers = QuizModel::get_answers_by_quiz_question( $question_id );
2215 $question_randomized_answers = array();
2216
2217 // Ordering questions always use a randomized answer list.
2218 // Matching and image matching keep the drop-zone items ordered and only shuffle the draggable choices.
2219
2220 if ( 'ordering' === $question_type ) {
2221 $question_answers = QuizModel::get_answers_by_quiz_question( $question_id, true );
2222 } elseif ( 'matching' === $question_type || 'image_matching' === $question_type ) {
2223 $question_randomized_answers = QuizModel::get_answers_by_quiz_question( $question_id, $rand_choice );
2224 } elseif ( $rand_choice ) {
2225 $question_answers = QuizModel::get_answers_by_quiz_question( $question_id, true );
2226 }
2227
2228 $question_answers = array_map(
2229 static fn( $answer ) => (array) $answer,
2230 $question_answers
2231 );
2232 $question_randomized_answers = array_map(
2233 static fn( $answer ) => (array) $answer,
2234 $question_randomized_answers
2235 );
2236
2237 return array(
2238 'question_answers' => $question_answers,
2239 'question_randomized_answers' => $question_randomized_answers,
2240 );
2241 }
2242
2243 /**
2244 * Renders the assignment status icon for the learning area navigation.
2245 *
2246 * @since 4.0.0
2247 *
2248 * @param WP_Post $quiz The assignment post object.
2249 * @param bool $can_access Whether the current user can access the assignment.
2250 * @param int $tutor_current_content_id Current content id.
2251 *
2252 * @return void
2253 */
2254 public static function render_sidebar_nav( WP_Post $quiz, $can_access, $tutor_current_content_id ) {
2255 $quiz_status = '';
2256 $icon_name = Icon::QUIZ_2;
2257 if ( ! $can_access ) {
2258 $icon_name = Icon::LOCK_STROKE_2;
2259 } else {
2260 $last_attempt = ( new QuizModel() )->get_first_or_last_attempt( $quiz->ID );
2261 $attempt_ended = is_object( $last_attempt ) && QuizModel::ATTEMPT_STARTED !== $last_attempt->attempt_status;
2262
2263 $quiz_result = QuizModel::get_quiz_result( $quiz->ID );
2264 if ( $attempt_ended && QuizModel::ATTEMPT_STARTED !== $last_attempt->attempt_status ) {
2265 if ( QuizModel::RESULT_FAIL === $quiz_result ) {
2266 $icon_name = Icon::CROSS_COLORIZE;
2267 $quiz_status = QuizModel::RESULT_FAIL;
2268 } elseif ( QuizModel::RESULT_PENDING === $quiz_result ) {
2269 $icon_name = Icon::INFO_COLORIZE;
2270 $quiz_status = QuizModel::RESULT_PENDING;
2271 } elseif ( QuizModel::RESULT_PASS === $quiz_result ) {
2272 $icon_name = Icon::COMPLETED_COLORIZE;
2273 }
2274 }
2275 }
2276
2277 tutor_load_template(
2278 'learning-area.components.sidebar-nav-item',
2279 array(
2280 'item' => $quiz,
2281 'active' => $tutor_current_content_id === $quiz->ID,
2282 'can_access' => $can_access,
2283 'is_completed' => false,
2284 'type_label' => __( 'Quiz', 'tutor' ),
2285 'icon' => $icon_name,
2286 'status_class' => $quiz_status,
2287 )
2288 );
2289 }
2290
2291 /**
2292 * Sanitize quiz content
2293 *
2294 * @param string $content Content to sanitize.
2295 * @return string
2296 *
2297 * @since 4.0.5
2298 */
2299 public static function sanitize_quiz_content( $content ) {
2300 if ( empty( $content ) ) {
2301 return '';
2302 }
2303
2304 return wp_strip_all_tags( wp_unslash( (string) $content ) );
2305 }
2306 }
2307