PluginProbe
Tutor LMS – eLearning and online course solution / 3.9.2
Tutor LMS – eLearning and online course solution v3.9.2
4.0.7 4.0.6 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 All 191 releases
tutor / classes / Quiz.php

Quiz.php in Tutor LMS – eLearning and online course solution 3.9.2, at classes/Quiz.php

1,264 lines 34.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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 if ( ! defined( 'ABSPATH' ) ) {
14 exit;
15 }
16
17 use Tutor\Helpers\HttpHelper;
18 use Tutor\Helpers\QueryHelper;
19 use Tutor\Models\CourseModel;
20 use Tutor\Models\QuizModel;
21 use Tutor\Traits\JsonResponse;
22
23 /**
24 * Manage quiz operations.
25 *
26 * @since 1.0.0
27 */
28 class Quiz {
29 use JsonResponse;
30
31 const META_QUIZ_OPTION = 'tutor_quiz_option';
32
33 /**
34 * Allowed attrs
35 *
36 * @var array
37 */
38 private $allowed_attributes = array(
39 'src' => array(),
40 'style' => array(),
41 'class' => array(),
42 'id' => array(),
43 'href' => array(),
44 'alt' => array(),
45 'title' => array(),
46 'type' => array(),
47 'controls' => array(),
48 'muted' => array(),
49 'loop' => array(),
50 'poster' => array(),
51 'preload' => array(),
52 'autoplay' => array(),
53 'width' => array(),
54 'height' => array(),
55 );
56
57 /**
58 * Allowed HTML tags
59 *
60 * @var array
61 */
62 private $allowed_html = array( 'img', 'b', 'i', 'br', 'a', 'audio', 'video', 'source' );
63
64 /**
65 * Register hooks
66 *
67 * @since 1.0.0
68 *
69 * @return void
70 */
71 public function __construct() {
72 add_action( 'wp_ajax_tutor_quiz_timeout', array( $this, 'tutor_quiz_timeout' ) );
73
74 // User take the quiz.
75 add_action( 'template_redirect', array( $this, 'start_the_quiz' ) );
76 add_action( 'template_redirect', array( $this, 'answering_quiz' ) );
77 add_action( 'template_redirect', array( $this, 'finishing_quiz_attempt' ) );
78
79 /**
80 * Instructor quiz review and feedback Ajax API.
81 */
82 add_action( 'wp_ajax_review_quiz_answer', array( $this, 'review_quiz_answer' ) );
83 add_action( 'wp_ajax_tutor_instructor_feedback', array( $this, 'tutor_instructor_feedback' ) );
84
85 /**
86 * New quiz builder Ajax API.
87 */
88 add_action( 'wp_ajax_tutor_quiz_details', array( $this, 'ajax_quiz_details' ) );
89 add_action( 'wp_ajax_tutor_quiz_delete', array( $this, 'ajax_quiz_delete' ) );
90
91 /**
92 * Frontend Stuff
93 */
94 add_action( 'wp_ajax_tutor_render_quiz_content', array( $this, 'tutor_render_quiz_content' ) );
95
96 /**
97 * Quiz abandon action
98 *
99 * @since 1.9.6
100 */
101 add_action( 'wp_ajax_tutor_quiz_abandon', array( $this, 'tutor_quiz_abandon' ) );
102
103 $this->prepare_allowed_html();
104
105 /**
106 * Delete quiz attempt
107 *
108 * @since 2.1.0
109 */
110 add_action( 'wp_ajax_tutor_attempt_delete', array( $this, 'attempt_delete' ) );
111
112 add_action( 'tutor_quiz/answer/review/after', array( $this, 'do_auto_course_complete' ), 10, 3 );
113 }
114
115 /**
116 * Get quiz time units options.
117 *
118 * @since 2.6.0
119 *
120 * @return array
121 */
122 public static function quiz_time_units() {
123 $time_units = array(
124 'seconds' => __( 'Seconds', 'tutor' ),
125 'minutes' => __( 'Minutes', 'tutor' ),
126 'hours' => __( 'Hours', 'tutor' ),
127 'days' => __( 'Days', 'tutor' ),
128 'weeks' => __( 'Weeks', 'tutor' ),
129 );
130
131 return apply_filters( 'tutor_quiz_time_units', $time_units );
132 }
133
134 /**
135 * Get quiz default settings.
136 *
137 * @since 3.0.0
138 *
139 * @return array
140 */
141 public static function get_default_quiz_settings() {
142 $settings = array(
143 'time_limit' => array(
144 'time_type' => 'minutes',
145 'time_value' => 0,
146 ),
147 'attempts_allowed' => 10,
148 'feedback_mode' => 'retry',
149 'hide_question_number_overview' => 0,
150 'hide_quiz_time_display' => 0,
151 'max_questions_for_answer' => 10,
152 'open_ended_answer_characters_limit' => 500,
153 'pass_is_required' => 0,
154 'passing_grade' => 80,
155 'question_layout_view' => '',
156 'questions_order' => 'rand',
157 'quiz_auto_start' => 0,
158 'short_answer_characters_limit' => 200,
159 );
160
161 return apply_filters( 'tutor_quiz_default_settings', $settings );
162 }
163
164 /**
165 * Get question default settings.
166 *
167 * @since 3.0.0
168 *
169 * @param string $type type of question.
170 *
171 * @return array
172 */
173 public static function get_default_question_settings( $type ) {
174 $settings = array(
175 'question_type' => $type,
176 'question_mark' => 1,
177 'answer_required' => 0,
178 'randomize_question' => 0,
179 'show_question_mark' => 0,
180 );
181
182 return apply_filters( 'tutor_question_default_settings', $settings );
183 }
184
185 /**
186 * Get quiz modes
187 *
188 * @since 2.6.0
189 *
190 * @return array
191 */
192 public static function quiz_modes() {
193 $modes = array(
194 array(
195 'key' => 'default',
196 'value' => __( 'Default', 'tutor' ),
197 'description' => __( 'Answers shown after quiz is finished', 'tutor' ),
198 ),
199 array(
200 'key' => 'reveal',
201 'value' => __( 'Reveal Mode', 'tutor' ),
202 'description' => __( 'Show result after the attempt.', 'tutor' ),
203 ),
204 array(
205 'key' => 'retry',
206 'value' => __( 'Retry Mode', 'tutor' ),
207 'description' => __( 'Reattempt quiz any number of times. Define Attempts Allowed below.', 'tutor' ),
208 ),
209 );
210
211 return apply_filters( 'tutor_quiz_modes', $modes );
212 }
213
214 /**
215 * Get quiz modes
216 *
217 * @since 2.6.0
218 *
219 * @return array
220 */
221 public static function quiz_question_layouts() {
222 $layouts = array(
223 '' => __( 'Set question layout view', 'tutor' ),
224 'single_question' => __( 'Single Question', 'tutor' ),
225 'question_pagination' => __( 'Question Pagination', 'tutor' ),
226 'question_below_each_other' => __( 'Question below each other', 'tutor' ),
227 );
228
229 return apply_filters( 'tutor_quiz_layouts', $layouts );
230 }
231
232 /**
233 * Get quiz modes
234 *
235 * @since 2.6.0
236 *
237 * @return array
238 */
239 public static function quiz_question_orders() {
240 $orders = array(
241 'rand' => __( 'Random', 'tutor' ),
242 'sorting' => __( 'Sorting', 'tutor' ),
243 'asc' => __( 'Ascending', 'tutor' ),
244 'desc' => __( 'Descending', 'tutor' ),
245 );
246
247 return apply_filters( 'tutor_quiz_layouts', $orders );
248 }
249
250 /**
251 * Prepare allowed HTML
252 *
253 * @since 1.0.0
254 *
255 * @return void
256 */
257 private function prepare_allowed_html() {
258
259 $allowed = array();
260
261 foreach ( $this->allowed_html as $tag ) {
262 $allowed[ $tag ] = $this->allowed_attributes;
263 }
264
265 $this->allowed_html = $allowed;
266 }
267
268 /**
269 * Instructor feedback ajax request handler
270 *
271 * @since 1.0.0
272 *
273 * @return void | send json response
274 */
275 public function tutor_instructor_feedback() {
276 tutor_utils()->checking_nonce();
277
278 // Check if user is privileged.
279 if ( ! User::has_any_role( array( User::ADMIN, User::INSTRUCTOR ) ) ) {
280 wp_send_json_error( tutor_utils()->error_message() );
281 }
282
283 $attempt_details = self::attempt_details( Input::post( 'attempt_id', 0, Input::TYPE_INT ) );
284 $feedback = Input::post( 'feedback', '', Input::TYPE_KSES_POST );
285 $attempt_info = isset( $attempt_details->attempt_info ) ? $attempt_details->attempt_info : false;
286 if ( $attempt_info ) {
287 //phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize
288 $unserialized = unserialize( $attempt_details->attempt_info );
289 if ( is_array( $unserialized ) ) {
290 $unserialized['instructor_feedback'] = $feedback;
291
292 //phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize
293 $update = self::update_attempt_info( $attempt_details->attempt_id, serialize( $unserialized ) );
294 if ( $update ) {
295 do_action( 'tutor_quiz/attempt/submitted/feedback', $attempt_details->attempt_id );
296 wp_send_json_success();
297 } else {
298 wp_send_json_error();
299 }
300 } else {
301 wp_send_json_error( __( 'Invalid quiz info', 'tutor' ) );
302 }
303 }
304 wp_send_json_error();
305 }
306
307 /**
308 * Start Quiz from here...
309 *
310 * @since 1.0.0
311 *
312 * @return void
313 */
314 public function start_the_quiz() {
315 if ( 'tutor_start_quiz' !== Input::post( 'tutor_action' ) ) {
316 return;
317 }
318
319 tutor_utils()->checking_nonce();
320
321 if ( ! is_user_logged_in() ) {
322 die( esc_html__( 'Please sign in to do this operation', 'tutor' ) );
323 }
324
325 $user_id = get_current_user_id();
326 $quiz_id = Input::post( 'quiz_id', 0, Input::TYPE_INT );
327 $course = CourseModel::get_course_by_quiz( $quiz_id );
328
329 self::quiz_attempt( $course->ID, $quiz_id, $user_id );
330 wp_safe_redirect( get_permalink( $quiz_id ) );
331 die();
332 }
333
334 /**
335 * Manage quiz attempt
336 *
337 * @since 2.6.1
338 *
339 * @param integer $course_id course id.
340 * @param integer $quiz_id quiz id.
341 * @param integer $user_id user id.
342 * @param string $attempt_status attempt status.
343 *
344 * @return int inserted id|0
345 */
346 public static function quiz_attempt( int $course_id, int $quiz_id, int $user_id, $attempt_status = 'attempt_started' ) {
347 global $wpdb;
348
349 if ( ! $course_id ) {
350 die( 'There is something went wrong with course, please check if quiz attached with a course' );
351 }
352
353 do_action( 'tutor_quiz/start/before', $quiz_id, $user_id );
354
355 $date = date( 'Y-m-d H:i:s', tutor_time() ); //phpcs:ignore
356
357 $tutor_quiz_option = (array) maybe_unserialize( get_post_meta( $quiz_id, 'tutor_quiz_option', true ) );
358 $attempts_allowed = tutor_utils()->get_quiz_option( $quiz_id, 'attempts_allowed', 0 );
359
360 $time_limit = tutor_utils()->get_quiz_option( $quiz_id, 'time_limit.time_value' );
361 $time_limit_seconds = 0;
362 $time_type = 'seconds';
363 if ( $time_limit ) {
364 $time_type = tutor_utils()->get_quiz_option( $quiz_id, 'time_limit.time_type' );
365
366 switch ( $time_type ) {
367 case 'seconds':
368 $time_limit_seconds = $time_limit;
369 break;
370 case 'minutes':
371 $time_limit_seconds = $time_limit * 60;
372 break;
373 case 'hours':
374 $time_limit_seconds = $time_limit * 60 * 60;
375 break;
376 case 'days':
377 $time_limit_seconds = $time_limit * 60 * 60 * 24;
378 break;
379 case 'weeks':
380 $time_limit_seconds = $time_limit * 60 * 60 * 24 * 7;
381 break;
382 }
383 }
384
385 $max_question_allowed = tutor_utils()->max_questions_for_take_quiz( $quiz_id );
386 $tutor_quiz_option['time_limit']['time_limit_seconds'] = $time_limit_seconds;
387
388 $attempt_data = array(
389 'course_id' => $course_id,
390 'quiz_id' => $quiz_id,
391 'user_id' => $user_id,
392 'total_questions' => $max_question_allowed,
393 'total_answered_questions' => 0,
394 'attempt_info' => maybe_serialize( $tutor_quiz_option ),
395 'attempt_status' => $attempt_status,
396 'attempt_ip' => tutor_utils()->get_ip(),
397 'attempt_started_at' => $date,
398 );
399
400 $wpdb->insert( $wpdb->prefix . 'tutor_quiz_attempts', $attempt_data );
401 $attempt_id = (int) $wpdb->insert_id;
402
403 if ( $attempt_id ) {
404 do_action( 'tutor_quiz/start/after', $quiz_id, $user_id, $attempt_id );
405 return $attempt_id;
406 } else {
407 return 0;
408 }
409 }
410
411 /**
412 * Answering quiz
413 *
414 * @since 1.0.0
415 *
416 * @return void
417 */
418 public function answering_quiz() {
419
420 if ( Input::post( 'tutor_action' ) !== 'tutor_answering_quiz_question' ) {
421 return;
422 }
423 // submit quiz attempts.
424 self::tutor_quiz_attempt_submit();
425
426 wp_safe_redirect( get_the_permalink() );
427 die();
428 }
429
430 /**
431 * Quiz abandon submission handler
432 *
433 * @since 1.9.6
434 *
435 * @return JSON response
436 */
437 public function tutor_quiz_abandon() {
438 if ( Input::post( 'tutor_action' ) !== 'tutor_answering_quiz_question' ) {
439 return;
440 }
441 tutor_utils()->checking_nonce();
442 // submit quiz attempts.
443 if ( self::tutor_quiz_attempt_submit() ) {
444 wp_send_json_success();
445 } else {
446 wp_send_json_error();
447 }
448 }
449
450 /**
451 * This is a unified method for handling normal quiz submit or abandon submit
452 * It will handle ajax or normal form submit and can be used with different hooks
453 *
454 * @since 1.9.6
455 *
456 * @return true | false
457 */
458 public static function tutor_quiz_attempt_submit() {
459 // Check logged in.
460 if ( ! is_user_logged_in() ) {
461 die( 'Please sign in to do this operation' );
462 }
463
464 // Check nonce.
465 tutor_utils()->checking_nonce();
466
467 // Prepare attempt info.
468 $user_id = get_current_user_id();
469 $attempt_id = Input::post( 'attempt_id', 0, Input::TYPE_INT );
470 $attempt = tutor_utils()->get_attempt( $attempt_id );
471 $course_id = CourseModel::get_course_by_quiz( $attempt->quiz_id )->ID;
472
473 if ( QuizModel::ATTEMPT_TIMEOUT === $attempt->attempt_status ) {
474 return false;
475 }
476
477 // Sanitize data by helper method.
478 $attempt_answers = isset( $_POST['attempt'] ) ? tutor_sanitize_data( $_POST['attempt'] ) : false; //phpcs:ignore
479 $attempt_answers = is_array( $attempt_answers ) ? $attempt_answers : array();
480
481 // Check if has access to the attempt.
482 if ( ! $attempt || $user_id != $attempt->user_id ) {
483 die( 'Operation not allowed, attempt not found or permission denied' );
484 }
485 self::manage_attempt_answers( $attempt_answers, $attempt, $attempt_id, $course_id, $user_id );
486 return true;
487 }
488
489 /**
490 * Manage attempt answers
491 *
492 * Evaluate each attempt answer and update the attempts table & insert in the attempt_answers table.
493 *
494 * @since 2.6.1
495 *
496 * @param array $attempt_answers attempt answers.
497 * @param object $attempt single attempt.
498 * @param int $attempt_id attempt id.
499 * @param int $course_id course id.
500 * @param int $user_id user id.
501 *
502 * @return void
503 */
504 public static function manage_attempt_answers( $attempt_answers, $attempt, $attempt_id, $course_id, $user_id ) {
505 global $wpdb;
506 // Before hook.
507 do_action( 'tutor_quiz/attempt_analysing/before', $attempt_id );
508
509 // Single quiz can have multiple question. So multiple answer should be saved.
510 foreach ( $attempt_answers as $attempt_id => $attempt_answer ) {
511 // Get total marks of all question comes.
512 $question_ids = tutor_utils()->avalue_dot( 'quiz_question_ids', $attempt_answer );
513 $question_ids = array_filter(
514 $question_ids,
515 function ( $id ) {
516 return (int) $id;
517 }
518 );
519
520 // Calculate and set the total marks in attempt table for this question.
521 if ( is_array( $question_ids ) && count( $question_ids ) ) {
522 $question_ids_string = QueryHelper::prepare_in_clause( $question_ids );
523
524 // Get total marks of the questions from question table.
525 //phpcs:disable
526 $query = $wpdb->prepare(
527 "SELECT SUM(question_mark)
528 FROM {$wpdb->prefix}tutor_quiz_questions
529 WHERE 1 = %d
530 AND question_id IN({$question_ids_string});
531 ",
532 1
533 );
534 $total_question_marks = $wpdb->get_var( $query );
535 //phpcs:enable
536
537 $total_question_marks = apply_filters( 'tutor_filter_update_before_question_mark', $total_question_marks, $question_ids, $user_id, $attempt_id );
538
539 // Set the the total mark in the attempt table for the question.
540 $wpdb->update(
541 $wpdb->prefix . 'tutor_quiz_attempts',
542 array( 'total_marks' => $total_question_marks ),
543 array( 'attempt_id' => $attempt_id )
544 );
545 }
546
547 $total_marks = 0;
548 $review_required = false;
549 $quiz_answers = tutor_utils()->avalue_dot( 'quiz_question', $attempt_answer );
550
551 if ( tutor_utils()->count( $quiz_answers ) ) {
552
553 foreach ( $quiz_answers as $question_id => $answers ) {
554 $question = QuizModel::get_quiz_question_by_id( $question_id );
555 $question_type = $question->question_type;
556
557 $is_answer_was_correct = false;
558 $given_answer = '';
559
560 if ( 'true_false' === $question_type || 'single_choice' === $question_type ) {
561
562 if ( ! is_numeric( $answers ) || ! $answers ) {
563 wp_send_json_error();
564 exit;
565 }
566
567 $given_answer = $answers;
568 $is_answer_was_correct = (bool) $wpdb->get_var(
569 $wpdb->prepare(
570 "SELECT is_correct
571 FROM {$wpdb->prefix}tutor_quiz_question_answers
572 WHERE answer_id = %d
573 ",
574 $answers
575 )
576 );
577
578 } elseif ( 'multiple_choice' === $question_type ) {
579
580 $given_answer = (array) ( $answers );
581
582 $given_answer = array_filter(
583 $given_answer,
584 function ( $id ) {
585 return is_numeric( $id ) && $id > 0;
586 }
587 );
588 $get_original_answers = (array) $wpdb->get_col(
589 $wpdb->prepare(
590 "SELECT
591 answer_id
592 FROM
593 {$wpdb->prefix}tutor_quiz_question_answers
594 WHERE belongs_question_id = %d
595 AND belongs_question_type = %s
596 AND is_correct = 1 ;
597 ",
598 $question->question_id,
599 $question_type
600 )
601 );
602
603 if ( count( array_diff( $get_original_answers, $given_answer ) ) === 0 && count( $get_original_answers ) === count( $given_answer ) ) {
604 $is_answer_was_correct = true;
605 }
606 $given_answer = maybe_serialize( $answers );
607
608 } elseif ( 'fill_in_the_blank' === $question_type ) {
609
610 $get_original_answer = $wpdb->get_row(
611 $wpdb->prepare(
612 "SELECT *
613 FROM {$wpdb->prefix}tutor_quiz_question_answers
614 WHERE belongs_question_id = %d
615 AND belongs_question_type = %s ;
616 ",
617 $question->question_id,
618 $question_type
619 )
620 );
621
622 /**
623 * Answers stored in DB
624 */
625 $gap_answer = (array) explode( '|', $get_original_answer->answer_two_gap_match );
626 $gap_answer = maybe_serialize(
627 array_map(
628 function ( $ans ) {
629 return wp_slash( trim( $ans ) );
630 },
631 $gap_answer
632 )
633 );
634
635 /**
636 * Answers from user input
637 */
638 $given_answer = (array) array_map( 'sanitize_text_field', $answers );
639 $given_answer = maybe_serialize( $given_answer );
640
641 /**
642 * Compare answer's by making both case-insensitive.
643 */
644 if ( strtolower( $given_answer ) == strtolower( $gap_answer ) ) {
645 $is_answer_was_correct = true;
646 }
647 } elseif ( 'open_ended' === $question_type || 'short_answer' === $question_type ) {
648 $review_required = true;
649 $given_answer = wp_kses_post( $answers );
650
651 } elseif ( 'ordering' === $question_type || 'matching' === $question_type || 'image_matching' === $question_type ) {
652
653 $given_answer = (array) array_map( 'sanitize_text_field', tutor_utils()->avalue_dot( 'answers', $answers ) );
654 $given_answer = maybe_serialize( $given_answer );
655
656 $get_original_answers = (array) $wpdb->get_col(
657 $wpdb->prepare(
658 "SELECT answer_id
659 FROM {$wpdb->prefix}tutor_quiz_question_answers
660 WHERE belongs_question_id = %d
661 AND belongs_question_type = %s
662 ORDER BY answer_order ASC ;
663 ",
664 $question->question_id,
665 $question_type
666 )
667 );
668
669 $get_original_answers = array_map( 'sanitize_text_field', $get_original_answers );
670
671 if ( maybe_serialize( $get_original_answers ) == $given_answer ) {
672 $is_answer_was_correct = true;
673 }
674 } elseif ( 'image_answering' === $question_type ) {
675 $image_inputs = tutor_utils()->avalue_dot( 'answer_id', $answers );
676 $image_inputs = (array) array_map( 'sanitize_text_field', $image_inputs );
677 $given_answer = maybe_serialize( $image_inputs );
678 $is_answer_was_correct = false;
679 /**
680 * For the image_answering question type result
681 * remain pending in spite of correct answer & required
682 * review of admin/instructor. Since it's
683 * pending we need to mark it as incorrect. Otherwise if
684 * mark it correct then earned mark will be updated. then
685 * again when instructor/admin review & mark it as correct
686 * extra mark is adding. In this case, student
687 * getting double mark for the same question.
688 *
689 * For now code is commenting will be removed later on
690 *
691 * @since 2.1.5
692 */
693
694 //phpcs:disable
695
696 // $db_answer = $wpdb->get_col(
697 // $wpdb->prepare(
698 // "SELECT answer_title
699 // FROM {$wpdb->prefix}tutor_quiz_question_answers
700 // WHERE belongs_question_id = %d
701 // AND belongs_question_type = 'image_answering'
702 // ORDER BY answer_order asc ;",
703 // $question_id
704 // )
705 // );
706
707 // if ( is_array( $db_answer ) && count( $db_answer ) ) {
708 // $is_answer_was_correct = ( strtolower( maybe_serialize( array_values( $image_inputs ) ) ) == strtolower( maybe_serialize( $db_answer ) ) );
709 // }
710 //phpcs:enable
711 }
712
713 $question_mark = $is_answer_was_correct ? $question->question_mark : 0;
714 $total_marks += $question_mark;
715
716 $total_marks = apply_filters( 'tutor_filter_quiz_total_marks', $total_marks, $question_id, $question_type, $user_id, $attempt_id );
717
718 $answers_data = array(
719 'user_id' => $user_id,
720 'quiz_id' => $attempt->quiz_id,
721 'question_id' => $question_id,
722 'quiz_attempt_id' => $attempt_id,
723 'given_answer' => $given_answer,
724 'question_mark' => $question->question_mark,
725 'achieved_mark' => $question_mark,
726 'minus_mark' => 0,
727 'is_correct' => $is_answer_was_correct ? 1 : 0,
728 );
729
730 /**
731 * Check if question_type open ended or short ans the set
732 * is_correct default value null before saving
733 */
734 if ( in_array( $question_type, array( 'open_ended', 'short_answer', 'image_answering' ) ) ) {
735 $answers_data['is_correct'] = null;
736 $review_required = true;
737 }
738
739 $answers_data = apply_filters( 'tutor_filter_quiz_answer_data', $answers_data, $question_id, $question_type, $user_id, $attempt_id );
740
741 $wpdb->insert( $wpdb->prefix . 'tutor_quiz_attempt_answers', $answers_data );
742 }
743 }
744
745 $attempt_info = array(
746 'total_answered_questions' => tutor_utils()->count( $quiz_answers ),
747 'earned_marks' => $total_marks,
748 'attempt_status' => 'attempt_ended',
749 'attempt_ended_at' => date( 'Y-m-d H:i:s', tutor_time() ), //phpcs:ignore
750 );
751
752 if ( $review_required ) {
753 $attempt_info['attempt_status'] = 'review_required';
754 }
755
756 $wpdb->update( $wpdb->tutor_quiz_attempts, $attempt_info, array( 'attempt_id' => $attempt_id ) );
757
758 QuizModel::update_attempt_result( $attempt_id );
759 }
760
761 // After hook.
762 do_action( 'tutor_quiz/attempt_ended', $attempt_id, $course_id, $user_id );
763 }
764
765
766 /**
767 * Quiz attempt will be finish here
768 *
769 * @since 1.0.0
770 *
771 * @return void
772 */
773 public function finishing_quiz_attempt() {
774
775 if ( Input::post( 'tutor_action' ) !== 'tutor_finish_quiz_attempt' ) {
776 return;
777 }
778 // Checking nonce.
779 tutor_utils()->checking_nonce();
780
781 if ( ! is_user_logged_in() ) {
782 die( 'Please sign in to do this operation' );
783 }
784
785 global $wpdb;
786
787 $quiz_id = Input::post( 'quiz_id', 0, Input::TYPE_INT );
788 $attempt = tutor_utils()->is_started_quiz( $quiz_id );
789 $attempt_id = $attempt->attempt_id;
790
791 $attempt_info = array(
792 'total_answered_questions' => 0,
793 'earned_marks' => 0,
794 'attempt_status' => 'attempt_ended',
795 'attempt_ended_at' => date( 'Y-m-d H:i:s', tutor_time() ), //phpcs:ignore
796 );
797
798 do_action( 'tutor_quiz_before_finish', $attempt_id, $quiz_id, $attempt->user_id );
799 $wpdb->update( $wpdb->prefix . 'tutor_quiz_attempts', $attempt_info, array( 'attempt_id' => $attempt_id ) );
800 do_action( 'tutor_quiz_finished', $attempt_id, $quiz_id, $attempt->user_id );
801
802 wp_redirect( tutor_utils()->input_old( '_wp_http_referer' ) );
803 }
804
805 /**
806 * Get quiz total marks.
807 *
808 * @since 3.0.0
809 *
810 * @param int $quiz_id quiz id.
811 *
812 * @return int|float
813 */
814 public static function get_quiz_total_marks( $quiz_id ) {
815 global $wpdb;
816
817 $total_marks = $wpdb->get_var(
818 $wpdb->prepare(
819 "SELECT SUM(question_mark) total_marks
820 FROM {$wpdb->prefix}tutor_quiz_questions
821 WHERE quiz_id=%d",
822 $quiz_id
823 )
824 );
825
826 return floatval( $total_marks );
827 }
828
829 /**
830 * Quiz timeout by ajax
831 *
832 * @since 1.0.0
833 *
834 * @return void
835 */
836 public function tutor_quiz_timeout() {
837 tutils()->checking_nonce();
838
839 global $wpdb;
840
841 $quiz_id = Input::post( 'quiz_id', 0, Input::TYPE_INT );
842 $attempt = tutor_utils()->is_started_quiz( $quiz_id );
843
844 if ( $attempt ) {
845 $attempt_id = $attempt->attempt_id;
846
847 $data = array(
848 'attempt_status' => 'attempt_timeout',
849 'total_marks' => self::get_quiz_total_marks( $quiz_id ),
850 'earned_marks' => 0,
851 'attempt_ended_at' => gmdate( 'Y-m-d H:i:s', tutor_time() ),
852 );
853
854 $wpdb->update( $wpdb->prefix . 'tutor_quiz_attempts', $data, array( 'attempt_id' => $attempt->attempt_id ) );
855
856 do_action( 'tutor_quiz_timeout', $attempt_id, $quiz_id, $attempt->user_id );
857
858 wp_send_json_success();
859 }
860
861 wp_send_json_error( __( 'Quiz has been timeout already', 'tutor' ) );
862 }
863
864 /**
865 * Review quiz answer
866 *
867 * @since 1.0.0
868 *
869 * @return void
870 */
871 public function review_quiz_answer() {
872
873 tutor_utils()->checking_nonce();
874
875 global $wpdb;
876
877 $attempt_id = Input::post( 'attempt_id', 0, Input::TYPE_INT );
878 $context = Input::post( 'context' );
879 $attempt_answer_id = Input::post( 'attempt_answer_id', 0, Input::TYPE_INT );
880 $mark_as = Input::post( 'mark_as' );
881
882 if ( ! tutor_utils()->can_user_manage( 'attempt', $attempt_id ) || ! tutor_utils()->can_user_manage( 'attempt_answer', $attempt_answer_id ) ) {
883 wp_send_json_error( array( 'message' => __( 'Access Denied', 'tutor' ) ) );
884 }
885
886 $attempt_answer = $wpdb->get_row(
887 $wpdb->prepare(
888 "SELECT *
889 FROM {$wpdb->prefix}tutor_quiz_attempt_answers
890 WHERE attempt_answer_id = %d
891 ",
892 $attempt_answer_id
893 )
894 );
895
896 $attempt = tutor_utils()->get_attempt( $attempt_id );
897 $question = QuizModel::get_quiz_question_by_id( $attempt_answer->question_id );
898 $course_id = $attempt->course_id;
899 $student_id = $attempt->user_id;
900 $previous_ans = $attempt_answer->is_correct;
901
902 do_action( 'tutor_quiz_review_answer_before', $attempt_answer_id, $attempt_id, $mark_as );
903
904 if ( 'correct' === $mark_as ) {
905 $attempt_update_data = array();
906 $answer_update_data = array(
907 'achieved_mark' => $attempt_answer->question_mark,
908 'is_correct' => 1,
909 );
910
911 $wpdb->update( $wpdb->prefix . 'tutor_quiz_attempt_answers', $answer_update_data, array( 'attempt_answer_id' => $attempt_answer_id ) );
912
913 if ( 0 == $previous_ans || null == $previous_ans ) {
914 // if previous answer was wrong or in review then add point as correct.
915 $attempt_update_data = array(
916 'earned_marks' => $attempt->earned_marks + $attempt_answer->question_mark,
917 'is_manually_reviewed' => 1,
918 'manually_reviewed_at' => date( 'Y-m-d H:i:s', tutor_time() ), //phpcs:ignore
919 );
920 }
921
922 if ( 'open_ended' === $question->question_type || 'short_answer' === $question->question_type ) {
923 $attempt_update_data['attempt_status'] = 'attempt_ended';
924 }
925
926 if ( ! empty( $attempt_update_data ) ) {
927 $wpdb->update( $wpdb->tutor_quiz_attempts, $attempt_update_data, array( 'attempt_id' => $attempt_id ) );
928 }
929 } elseif ( 'incorrect' === $mark_as ) {
930 $attempt_update_data = array();
931 $answer_update_data = array(
932 'achieved_mark' => '0.00',
933 'is_correct' => 0,
934 );
935
936 $wpdb->update( $wpdb->prefix . 'tutor_quiz_attempt_answers', $answer_update_data, array( 'attempt_answer_id' => $attempt_answer_id ) );
937
938 if ( 1 == $previous_ans ) {
939 // If previous ans was right then mynus.
940 $attempt_update_data = array(
941 'earned_marks' => $attempt->earned_marks - $attempt_answer->question_mark,
942 'is_manually_reviewed' => 1,
943 'manually_reviewed_at' => date( 'Y-m-d H:i:s', tutor_time() ),//phpcs:ignore
944 );
945 }
946
947 if ( 'open_ended' === $question->question_type || 'short_answer' === $question->question_type ) {
948 $attempt_update_data['attempt_status'] = 'attempt_ended';
949 }
950
951 if ( ! empty( $attempt_update_data ) ) {
952 $wpdb->update( $wpdb->tutor_quiz_attempts, $attempt_update_data, array( 'attempt_id' => $attempt_id ) );
953 }
954 }
955
956 QuizModel::update_attempt_result( $attempt_id );
957
958 do_action( 'tutor_quiz_review_answer_after', $attempt_answer_id, $attempt_id, $mark_as );
959 do_action( 'tutor_quiz/answer/review/after', $attempt_answer_id, $course_id, $student_id );
960
961 ob_start();
962 tutor_load_template_from_custom_path(
963 tutor()->path . '/views/quiz/attempt-details.php',
964 array(
965 'attempt_id' => $attempt_id,
966 'user_id' => $student_id,
967 'context' => $context,
968 'back_url' => Input::post( 'back_url' ),
969 )
970 );
971 wp_send_json_success( array( 'html' => ob_get_clean() ) );
972 }
973
974 /**
975 * Do auto course complete after review a quiz attempt.
976 *
977 * @since 2.4.0
978 *
979 * @param int $attempt_answer_id attempt answer id.
980 * @param int $course_id course id.
981 * @param int $user_id student id.
982 *
983 * @return void
984 */
985 public function do_auto_course_complete( $attempt_answer_id, $course_id, $user_id ) {
986 if ( CourseModel::can_autocomplete_course( $course_id, $user_id ) ) {
987 CourseModel::mark_course_as_completed( $course_id, $user_id );
988 Course::set_review_popup_data( $user_id, $course_id );
989 }
990 }
991
992 /**
993 * Get a quiz details by id
994 *
995 * @return void
996 */
997 public function ajax_quiz_details() {
998 tutor_utils()->check_nonce();
999
1000 $quiz_id = Input::post( 'quiz_id', 0, Input::TYPE_INT );
1001 if ( ! tutor_utils()->can_user_manage( 'quiz', $quiz_id ) ) {
1002 $this->json_response(
1003 tutor_utils()->error_message(),
1004 null,
1005 HttpHelper::STATUS_FORBIDDEN
1006 );
1007 }
1008
1009 $data = QuizModel::get_quiz_details( $quiz_id );
1010
1011 $data = apply_filters( 'tutor_quiz_details_response', $data, $quiz_id );
1012
1013 $this->json_response(
1014 __( 'Quiz data fetched successfully', 'tutor' ),
1015 $data
1016 );
1017 }
1018
1019 /**
1020 * Delete quiz by id
1021 *
1022 * @since 1.0.0
1023 * @since 3.0.0 refactor and response change.
1024 *
1025 * @return void
1026 */
1027 public function ajax_quiz_delete() {
1028 if ( ! tutor_utils()->is_nonce_verified() ) {
1029 $this->json_response( tutor_utils()->error_message( 'nonce' ), null, HttpHelper::STATUS_BAD_REQUEST );
1030 }
1031
1032 global $wpdb;
1033
1034 $quiz_id = Input::post( 'quiz_id', 0, Input::TYPE_INT );
1035 if ( ! tutor_utils()->can_user_manage( 'quiz', $quiz_id ) ) {
1036 $this->json_response(
1037 tutor_utils()->error_message(),
1038 null,
1039 HttpHelper::STATUS_FORBIDDEN
1040 );
1041 }
1042
1043 $post = get_post( $quiz_id );
1044 if ( 'tutor_quiz' !== $post->post_type ) {
1045 $this->json_response(
1046 __( 'Invalid quiz', 'tutor' ),
1047 null,
1048 HttpHelper::STATUS_BAD_REQUEST
1049 );
1050 }
1051
1052 do_action( 'tutor_delete_quiz_before', $quiz_id );
1053
1054 $wpdb->delete( $wpdb->prefix . 'tutor_quiz_attempts', array( 'quiz_id' => $quiz_id ) );
1055 $wpdb->delete( $wpdb->prefix . 'tutor_quiz_attempt_answers', array( 'quiz_id' => $quiz_id ) );
1056
1057 $questions_ids = $wpdb->get_col( $wpdb->prepare( "SELECT question_id FROM {$wpdb->prefix}tutor_quiz_questions WHERE quiz_id = %d ", $quiz_id ) );
1058
1059 if ( is_array( $questions_ids ) && count( $questions_ids ) ) {
1060 $in_question_ids = QueryHelper::prepare_in_clause( $questions_ids );
1061 //phpcs:disable
1062 $wpdb->query(
1063 "DELETE
1064 FROM {$wpdb->prefix}tutor_quiz_question_answers
1065 WHERE belongs_question_id IN({$in_question_ids})
1066 "
1067 );
1068 //phpcs:enable
1069 }
1070
1071 $wpdb->delete( $wpdb->prefix . 'tutor_quiz_questions', array( 'quiz_id' => $quiz_id ) );
1072
1073 wp_delete_post( $quiz_id, true );
1074
1075 do_action( 'tutor_delete_quiz_after', $quiz_id );
1076
1077 $this->json_response(
1078 __( 'Quiz deleted successfully', 'tutor' ),
1079 $quiz_id
1080 );
1081 }
1082
1083 /**
1084 * Get answers by quiz id
1085 *
1086 * @since 1.0.0
1087 *
1088 * @param int $question_id question id.
1089 * @param mixed $question_type type of question.
1090 * @param boolean $is_correct only correct answers or not.
1091 *
1092 * @return wpdb:get_results
1093 */
1094 private function get_answers_by_q_id( $question_id, $question_type, $is_correct = false ) {
1095 global $wpdb;
1096
1097 $correct_clause = $is_correct ? ' AND is_correct=1 ' : '';
1098 //phpcs:disable
1099 return $wpdb->get_results(
1100 $wpdb->prepare(
1101 "SELECT * FROM {$wpdb->prefix}tutor_quiz_question_answers
1102 WHERE belongs_question_id = %d
1103 AND belongs_question_type = %s
1104 {$correct_clause}
1105 ORDER BY answer_order ASC;
1106 ",
1107 $question_id,
1108 esc_sql( $question_type )
1109 )
1110 );
1111 //phpcs:enable
1112 }
1113
1114 /**
1115 * Rendering quiz for frontend
1116 *
1117 * @since 1.0.0
1118 *
1119 * @return void send wp_json response
1120 */
1121 public function tutor_render_quiz_content() {
1122
1123 tutor_utils()->checking_nonce();
1124
1125 $quiz_id = Input::post( 'quiz_id', 0, Input::TYPE_INT );
1126
1127 if ( ! tutor_utils()->has_enrolled_content_access( 'quiz', $quiz_id ) ) {
1128 wp_send_json_error( array( 'message' => __( 'Access Denied.', 'tutor' ) ) );
1129 }
1130
1131 ob_start();
1132 global $post;
1133
1134 $post = get_post( $quiz_id ); //phpcs:ignore
1135 setup_postdata( $post );
1136
1137 single_quiz_contents();
1138 wp_reset_postdata();
1139
1140 $html = ob_get_clean();
1141 wp_send_json_success( array( 'html' => $html ) );
1142 }
1143
1144 /**
1145 * Get attempt details
1146 *
1147 * @since 1.0.0
1148 *
1149 * @param int $attempt_id required attempt id to get details.
1150 *
1151 * @return mixed object on success, null on failure
1152 */
1153 public static function attempt_details( int $attempt_id ) {
1154 global $wpdb;
1155 $attempt_details = $wpdb->get_row(
1156 $wpdb->prepare(
1157 "SELECT *
1158 FROM {$wpdb->prefix}tutor_quiz_attempts
1159 WHERE attempt_id = %d
1160 ",
1161 $attempt_id
1162 )
1163 );
1164 return $attempt_details;
1165 }
1166
1167 /**
1168 * Update quiz attempt info
1169 *
1170 * @since 1.0.0
1171 *
1172 * @param int $attempt_id attempt id.
1173 * @param mixed $attempt_info serialize data.
1174 *
1175 * @return bool, true on success, false on failure
1176 */
1177 public static function update_attempt_info( int $attempt_id, $attempt_info ) {
1178 global $wpdb;
1179 $table = $wpdb->prefix . 'tutor_quiz_attempts';
1180 $update_info = $wpdb->update(
1181 $table,
1182 array( 'attempt_info' => $attempt_info ),
1183 array( 'attempt_id' => $attempt_id )
1184 );
1185 return $update_info ? true : false;
1186 }
1187
1188 /**
1189 * Attempt delete ajax request handler
1190 *
1191 * @since 2.1.0
1192 *
1193 * @return void wp_json response
1194 */
1195 public function attempt_delete() {
1196 tutor_utils()->checking_nonce();
1197
1198 $attempt_id = Input::post( 'id', 0, Input::TYPE_INT );
1199 $attempt = tutor_utils()->get_attempt( $attempt_id );
1200 if ( ! $attempt ) {
1201 wp_send_json_error( __( 'Invalid attempt ID', 'tutor' ) );
1202 }
1203
1204 $user_id = get_current_user_id();
1205 $course_id = $attempt->course_id;
1206
1207 if ( tutor_utils()->can_user_edit_course( $user_id, $course_id ) ) {
1208 QuizModel::delete_quiz_attempt( $attempt_id );
1209 wp_send_json_success( __( 'Attempt deleted successfully!', 'tutor' ) );
1210 } else {
1211 wp_send_json_error( tutor_utils()->error_message() );
1212 }
1213 }
1214
1215 /**
1216 * Get all quiz attempts for a user in a specific course.
1217 *
1218 * @since 3.8.1
1219 *
1220 * @param int $course_id The ID of the course.
1221 * @param int $user_id The ID of the user.
1222 *
1223 * @return array Returns an array of quiz attempt objects with their answers, or an empty array on error.
1224 */
1225 public function get_quiz_attempts_and_answers_by_course_id( int $course_id ): array {
1226 global $wpdb;
1227
1228 $results = QueryHelper::get_all( $wpdb->tutor_quiz_attempts, array( 'course_id' => $course_id ), 'course_id', -1 );
1229
1230 if ( empty( $results ) ) {
1231 return array();
1232 }
1233
1234 return array_map(
1235 function ( $item ) {
1236 $item->quiz_attempt_answers = $this->get_quiz_attempt_answers_by_attempt_id( $item->attempt_id );
1237 return $item;
1238 },
1239 $results
1240 );
1241 }
1242
1243 /**
1244 * Get all quiz attempt answers for a specific quiz attempt.
1245 *
1246 * @since 3.8.1
1247 *
1248 * @param int $attempt_id The ID of the quiz attempt.
1249 *
1250 * @return array Returns an array of quiz attempt answers objects, or an empty array on error.
1251 */
1252 private function get_quiz_attempt_answers_by_attempt_id( int $attempt_id ): array {
1253 global $wpdb;
1254
1255 $results = QueryHelper::get_all( $wpdb->tutor_quiz_attempt_answers, array( 'quiz_attempt_id' => $attempt_id ), 'quiz_attempt_id', -1 );
1256
1257 if ( empty( $results ) ) {
1258 return array();
1259 }
1260
1261 return $results;
1262 }
1263 }
1264