PluginProbe ʕ •ᴥ•ʔ
Tutor LMS – eLearning and online course solution / 3.9.8
Tutor LMS – eLearning and online course solution v3.9.8
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 11 months ago Admin.php 8 months ago Ajax.php 9 months ago Announcements.php 1 year ago Assets.php 7 months ago Backend_Page_Trait.php 1 year ago BaseController.php 1 year ago Config.php 11 months ago Container.php 11 months ago Course.php 3 months ago Course_Embed.php 3 years ago Course_Filter.php 1 year ago Course_List.php 5 months ago Course_Settings_Tabs.php 1 year ago Course_Widget.php 1 year ago Custom_Validation.php 3 years ago Dashboard.php 1 year ago Earnings.php 9 months ago FormHandler.php 2 years ago Frontend.php 1 year ago Gutenberg.php 1 year ago Icon.php 8 months ago Input.php 1 year ago Instructor.php 1 year ago Instructors_List.php 11 months ago Lesson.php 8 months ago Options_V2.php 7 months ago Permalink.php 2 years ago Post_types.php 1 year ago Private_Course_Access.php 1 year ago Q_And_A.php 10 months ago Question_Answers_List.php 11 months ago Quiz.php 5 months ago QuizBuilder.php 3 months ago Quiz_Attempts_List.php 9 months ago RestAPI.php 2 years ago Reviews.php 9 months ago Rewrite_Rules.php 2 years ago Shortcode.php 9 months ago Singleton.php 1 year ago Student.php 1 year ago Students_List.php 1 year ago Taxonomies.php 1 year ago Template.php 9 months ago Theme_Compatibility.php 3 years ago Tools.php 1 year ago Tools_V2.php 1 year ago Tutor.php 3 months ago TutorEDD.php 1 year ago Tutor_Base.php 2 years ago Tutor_Setup.php 8 months ago Upgrader.php 9 months ago User.php 4 months ago Utils.php 5 months ago Video_Stream.php 3 years ago WhatsNew.php 9 months ago Withdraw.php 1 year ago Withdraw_Requests_List.php 11 months ago WooCommerce.php 7 months ago
Quiz.php
1270 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 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 $course_id = tutor_utils()->avalue_dot( 'course_id', $attempt_details, 0 );
287 $is_instructor = tutor_utils()->is_instructor_of_this_course( get_current_user_id(), $course_id );
288 if ( ! current_user_can( 'manage_options' ) && ! $is_instructor ) {
289 wp_send_json_error( tutor_utils()->error_message() );
290 }
291
292 if ( $attempt_info ) {
293 //phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize
294 $unserialized = unserialize( $attempt_details->attempt_info );
295 if ( is_array( $unserialized ) ) {
296 $unserialized['instructor_feedback'] = $feedback;
297
298 //phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize
299 $update = self::update_attempt_info( $attempt_details->attempt_id, serialize( $unserialized ) );
300 if ( $update ) {
301 do_action( 'tutor_quiz/attempt/submitted/feedback', $attempt_details->attempt_id );
302 wp_send_json_success();
303 } else {
304 wp_send_json_error();
305 }
306 } else {
307 wp_send_json_error( __( 'Invalid quiz info', 'tutor' ) );
308 }
309 }
310 wp_send_json_error();
311 }
312
313 /**
314 * Start Quiz from here...
315 *
316 * @since 1.0.0
317 *
318 * @return void
319 */
320 public function start_the_quiz() {
321 if ( 'tutor_start_quiz' !== Input::post( 'tutor_action' ) ) {
322 return;
323 }
324
325 tutor_utils()->checking_nonce();
326
327 if ( ! is_user_logged_in() ) {
328 die( esc_html__( 'Please sign in to do this operation', 'tutor' ) );
329 }
330
331 $user_id = get_current_user_id();
332 $quiz_id = Input::post( 'quiz_id', 0, Input::TYPE_INT );
333 $course = CourseModel::get_course_by_quiz( $quiz_id );
334
335 self::quiz_attempt( $course->ID, $quiz_id, $user_id );
336 wp_safe_redirect( get_permalink( $quiz_id ) );
337 die();
338 }
339
340 /**
341 * Manage quiz attempt
342 *
343 * @since 2.6.1
344 *
345 * @param integer $course_id course id.
346 * @param integer $quiz_id quiz id.
347 * @param integer $user_id user id.
348 * @param string $attempt_status attempt status.
349 *
350 * @return int inserted id|0
351 */
352 public static function quiz_attempt( int $course_id, int $quiz_id, int $user_id, $attempt_status = 'attempt_started' ) {
353 global $wpdb;
354
355 if ( ! $course_id ) {
356 die( 'There is something went wrong with course, please check if quiz attached with a course' );
357 }
358
359 do_action( 'tutor_quiz/start/before', $quiz_id, $user_id );
360
361 $date = date( 'Y-m-d H:i:s', tutor_time() ); //phpcs:ignore
362
363 $tutor_quiz_option = (array) maybe_unserialize( get_post_meta( $quiz_id, 'tutor_quiz_option', true ) );
364 $attempts_allowed = tutor_utils()->get_quiz_option( $quiz_id, 'attempts_allowed', 0 );
365
366 $time_limit = tutor_utils()->get_quiz_option( $quiz_id, 'time_limit.time_value' );
367 $time_limit_seconds = 0;
368 $time_type = 'seconds';
369 if ( $time_limit ) {
370 $time_type = tutor_utils()->get_quiz_option( $quiz_id, 'time_limit.time_type' );
371
372 switch ( $time_type ) {
373 case 'seconds':
374 $time_limit_seconds = $time_limit;
375 break;
376 case 'minutes':
377 $time_limit_seconds = $time_limit * 60;
378 break;
379 case 'hours':
380 $time_limit_seconds = $time_limit * 60 * 60;
381 break;
382 case 'days':
383 $time_limit_seconds = $time_limit * 60 * 60 * 24;
384 break;
385 case 'weeks':
386 $time_limit_seconds = $time_limit * 60 * 60 * 24 * 7;
387 break;
388 }
389 }
390
391 $max_question_allowed = tutor_utils()->max_questions_for_take_quiz( $quiz_id );
392 $tutor_quiz_option['time_limit']['time_limit_seconds'] = $time_limit_seconds;
393
394 $attempt_data = array(
395 'course_id' => $course_id,
396 'quiz_id' => $quiz_id,
397 'user_id' => $user_id,
398 'total_questions' => $max_question_allowed,
399 'total_answered_questions' => 0,
400 'attempt_info' => maybe_serialize( $tutor_quiz_option ),
401 'attempt_status' => $attempt_status,
402 'attempt_ip' => tutor_utils()->get_ip(),
403 'attempt_started_at' => $date,
404 );
405
406 $wpdb->insert( $wpdb->prefix . 'tutor_quiz_attempts', $attempt_data );
407 $attempt_id = (int) $wpdb->insert_id;
408
409 if ( $attempt_id ) {
410 do_action( 'tutor_quiz/start/after', $quiz_id, $user_id, $attempt_id );
411 return $attempt_id;
412 } else {
413 return 0;
414 }
415 }
416
417 /**
418 * Answering quiz
419 *
420 * @since 1.0.0
421 *
422 * @return void
423 */
424 public function answering_quiz() {
425
426 if ( Input::post( 'tutor_action' ) !== 'tutor_answering_quiz_question' ) {
427 return;
428 }
429 // submit quiz attempts.
430 self::tutor_quiz_attempt_submit();
431
432 wp_safe_redirect( get_the_permalink() );
433 die();
434 }
435
436 /**
437 * Quiz abandon submission handler
438 *
439 * @since 1.9.6
440 *
441 * @return JSON response
442 */
443 public function tutor_quiz_abandon() {
444 if ( Input::post( 'tutor_action' ) !== 'tutor_answering_quiz_question' ) {
445 return;
446 }
447 tutor_utils()->checking_nonce();
448 // submit quiz attempts.
449 if ( self::tutor_quiz_attempt_submit() ) {
450 wp_send_json_success();
451 } else {
452 wp_send_json_error();
453 }
454 }
455
456 /**
457 * This is a unified method for handling normal quiz submit or abandon submit
458 * It will handle ajax or normal form submit and can be used with different hooks
459 *
460 * @since 1.9.6
461 *
462 * @return true | false
463 */
464 public static function tutor_quiz_attempt_submit() {
465 // Check logged in.
466 if ( ! is_user_logged_in() ) {
467 die( 'Please sign in to do this operation' );
468 }
469
470 // Check nonce.
471 tutor_utils()->checking_nonce();
472
473 // Prepare attempt info.
474 $user_id = get_current_user_id();
475 $attempt_id = Input::post( 'attempt_id', 0, Input::TYPE_INT );
476 $attempt = tutor_utils()->get_attempt( $attempt_id );
477 $course_id = CourseModel::get_course_by_quiz( $attempt->quiz_id )->ID;
478
479 if ( QuizModel::ATTEMPT_TIMEOUT === $attempt->attempt_status ) {
480 return false;
481 }
482
483 // Sanitize data by helper method.
484 $attempt_answers = isset( $_POST['attempt'] ) ? tutor_sanitize_data( $_POST['attempt'] ) : false; //phpcs:ignore
485 $attempt_answers = is_array( $attempt_answers ) ? $attempt_answers : array();
486
487 // Check if has access to the attempt.
488 if ( ! $attempt || $user_id != $attempt->user_id ) {
489 die( 'Operation not allowed, attempt not found or permission denied' );
490 }
491 self::manage_attempt_answers( $attempt_answers, $attempt, $attempt_id, $course_id, $user_id );
492 return true;
493 }
494
495 /**
496 * Manage attempt answers
497 *
498 * Evaluate each attempt answer and update the attempts table & insert in the attempt_answers table.
499 *
500 * @since 2.6.1
501 *
502 * @param array $attempt_answers attempt answers.
503 * @param object $attempt single attempt.
504 * @param int $attempt_id attempt id.
505 * @param int $course_id course id.
506 * @param int $user_id user id.
507 *
508 * @return void
509 */
510 public static function manage_attempt_answers( $attempt_answers, $attempt, $attempt_id, $course_id, $user_id ) {
511 global $wpdb;
512 // Before hook.
513 do_action( 'tutor_quiz/attempt_analysing/before', $attempt_id );
514
515 // Single quiz can have multiple question. So multiple answer should be saved.
516 foreach ( $attempt_answers as $attempt_id => $attempt_answer ) {
517 // Get total marks of all question comes.
518 $question_ids = tutor_utils()->avalue_dot( 'quiz_question_ids', $attempt_answer );
519 $question_ids = array_filter(
520 $question_ids,
521 function ( $id ) {
522 return (int) $id;
523 }
524 );
525
526 // Calculate and set the total marks in attempt table for this question.
527 if ( is_array( $question_ids ) && count( $question_ids ) ) {
528 $question_ids_string = QueryHelper::prepare_in_clause( $question_ids );
529
530 // Get total marks of the questions from question table.
531 //phpcs:disable
532 $query = $wpdb->prepare(
533 "SELECT SUM(question_mark)
534 FROM {$wpdb->prefix}tutor_quiz_questions
535 WHERE 1 = %d
536 AND question_id IN({$question_ids_string});
537 ",
538 1
539 );
540 $total_question_marks = $wpdb->get_var( $query );
541 //phpcs:enable
542
543 $total_question_marks = apply_filters( 'tutor_filter_update_before_question_mark', $total_question_marks, $question_ids, $user_id, $attempt_id );
544
545 // Set the the total mark in the attempt table for the question.
546 $wpdb->update(
547 $wpdb->prefix . 'tutor_quiz_attempts',
548 array( 'total_marks' => $total_question_marks ),
549 array( 'attempt_id' => $attempt_id )
550 );
551 }
552
553 $total_marks = 0;
554 $review_required = false;
555 $quiz_answers = tutor_utils()->avalue_dot( 'quiz_question', $attempt_answer );
556
557 if ( tutor_utils()->count( $quiz_answers ) ) {
558
559 foreach ( $quiz_answers as $question_id => $answers ) {
560 $question = QuizModel::get_quiz_question_by_id( $question_id );
561 $question_type = $question->question_type;
562
563 $is_answer_was_correct = false;
564 $given_answer = '';
565
566 if ( 'true_false' === $question_type || 'single_choice' === $question_type ) {
567
568 if ( ! is_numeric( $answers ) || ! $answers ) {
569 wp_send_json_error();
570 exit;
571 }
572
573 $given_answer = $answers;
574 $is_answer_was_correct = (bool) $wpdb->get_var(
575 $wpdb->prepare(
576 "SELECT is_correct
577 FROM {$wpdb->prefix}tutor_quiz_question_answers
578 WHERE answer_id = %d
579 ",
580 $answers
581 )
582 );
583
584 } elseif ( 'multiple_choice' === $question_type ) {
585
586 $given_answer = (array) ( $answers );
587
588 $given_answer = array_filter(
589 $given_answer,
590 function ( $id ) {
591 return is_numeric( $id ) && $id > 0;
592 }
593 );
594 $get_original_answers = (array) $wpdb->get_col(
595 $wpdb->prepare(
596 "SELECT
597 answer_id
598 FROM
599 {$wpdb->prefix}tutor_quiz_question_answers
600 WHERE belongs_question_id = %d
601 AND belongs_question_type = %s
602 AND is_correct = 1 ;
603 ",
604 $question->question_id,
605 $question_type
606 )
607 );
608
609 if ( count( array_diff( $get_original_answers, $given_answer ) ) === 0 && count( $get_original_answers ) === count( $given_answer ) ) {
610 $is_answer_was_correct = true;
611 }
612 $given_answer = maybe_serialize( $answers );
613
614 } elseif ( 'fill_in_the_blank' === $question_type ) {
615
616 $get_original_answer = $wpdb->get_row(
617 $wpdb->prepare(
618 "SELECT *
619 FROM {$wpdb->prefix}tutor_quiz_question_answers
620 WHERE belongs_question_id = %d
621 AND belongs_question_type = %s ;
622 ",
623 $question->question_id,
624 $question_type
625 )
626 );
627
628 /**
629 * Answers stored in DB
630 */
631 $gap_answer = (array) explode( '|', $get_original_answer->answer_two_gap_match );
632 $gap_answer = maybe_serialize(
633 array_map(
634 function ( $ans ) {
635 return wp_slash( trim( $ans ) );
636 },
637 $gap_answer
638 )
639 );
640
641 /**
642 * Answers from user input
643 */
644 $given_answer = (array) array_map( 'sanitize_text_field', $answers );
645 $given_answer = maybe_serialize( $given_answer );
646
647 /**
648 * Compare answer's by making both case-insensitive.
649 */
650 if ( strtolower( $given_answer ) == strtolower( $gap_answer ) ) {
651 $is_answer_was_correct = true;
652 }
653 } elseif ( 'open_ended' === $question_type || 'short_answer' === $question_type ) {
654 $review_required = true;
655 $given_answer = wp_kses_post( $answers );
656
657 } elseif ( 'ordering' === $question_type || 'matching' === $question_type || 'image_matching' === $question_type ) {
658
659 $given_answer = (array) array_map( 'sanitize_text_field', tutor_utils()->avalue_dot( 'answers', $answers ) );
660 $given_answer = maybe_serialize( $given_answer );
661
662 $get_original_answers = (array) $wpdb->get_col(
663 $wpdb->prepare(
664 "SELECT answer_id
665 FROM {$wpdb->prefix}tutor_quiz_question_answers
666 WHERE belongs_question_id = %d
667 AND belongs_question_type = %s
668 ORDER BY answer_order ASC ;
669 ",
670 $question->question_id,
671 $question_type
672 )
673 );
674
675 $get_original_answers = array_map( 'sanitize_text_field', $get_original_answers );
676
677 if ( maybe_serialize( $get_original_answers ) == $given_answer ) {
678 $is_answer_was_correct = true;
679 }
680 } elseif ( 'image_answering' === $question_type ) {
681 $image_inputs = tutor_utils()->avalue_dot( 'answer_id', $answers );
682 $image_inputs = (array) array_map( 'sanitize_text_field', $image_inputs );
683 $given_answer = maybe_serialize( $image_inputs );
684 $is_answer_was_correct = false;
685 /**
686 * For the image_answering question type result
687 * remain pending in spite of correct answer & required
688 * review of admin/instructor. Since it's
689 * pending we need to mark it as incorrect. Otherwise if
690 * mark it correct then earned mark will be updated. then
691 * again when instructor/admin review & mark it as correct
692 * extra mark is adding. In this case, student
693 * getting double mark for the same question.
694 *
695 * For now code is commenting will be removed later on
696 *
697 * @since 2.1.5
698 */
699
700 //phpcs:disable
701
702 // $db_answer = $wpdb->get_col(
703 // $wpdb->prepare(
704 // "SELECT answer_title
705 // FROM {$wpdb->prefix}tutor_quiz_question_answers
706 // WHERE belongs_question_id = %d
707 // AND belongs_question_type = 'image_answering'
708 // ORDER BY answer_order asc ;",
709 // $question_id
710 // )
711 // );
712
713 // if ( is_array( $db_answer ) && count( $db_answer ) ) {
714 // $is_answer_was_correct = ( strtolower( maybe_serialize( array_values( $image_inputs ) ) ) == strtolower( maybe_serialize( $db_answer ) ) );
715 // }
716 //phpcs:enable
717 }
718
719 $question_mark = $is_answer_was_correct ? $question->question_mark : 0;
720 $total_marks += $question_mark;
721
722 $total_marks = apply_filters( 'tutor_filter_quiz_total_marks', $total_marks, $question_id, $question_type, $user_id, $attempt_id );
723
724 $answers_data = array(
725 'user_id' => $user_id,
726 'quiz_id' => $attempt->quiz_id,
727 'question_id' => $question_id,
728 'quiz_attempt_id' => $attempt_id,
729 'given_answer' => $given_answer,
730 'question_mark' => $question->question_mark,
731 'achieved_mark' => $question_mark,
732 'minus_mark' => 0,
733 'is_correct' => $is_answer_was_correct ? 1 : 0,
734 );
735
736 /**
737 * Check if question_type open ended or short ans the set
738 * is_correct default value null before saving
739 */
740 if ( in_array( $question_type, array( 'open_ended', 'short_answer', 'image_answering' ) ) ) {
741 $answers_data['is_correct'] = null;
742 $review_required = true;
743 }
744
745 $answers_data = apply_filters( 'tutor_filter_quiz_answer_data', $answers_data, $question_id, $question_type, $user_id, $attempt_id );
746
747 $wpdb->insert( $wpdb->prefix . 'tutor_quiz_attempt_answers', $answers_data );
748 }
749 }
750
751 $attempt_info = array(
752 'total_answered_questions' => tutor_utils()->count( $quiz_answers ),
753 'earned_marks' => $total_marks,
754 'attempt_status' => 'attempt_ended',
755 'attempt_ended_at' => date( 'Y-m-d H:i:s', tutor_time() ), //phpcs:ignore
756 );
757
758 if ( $review_required ) {
759 $attempt_info['attempt_status'] = 'review_required';
760 }
761
762 $wpdb->update( $wpdb->tutor_quiz_attempts, $attempt_info, array( 'attempt_id' => $attempt_id ) );
763
764 QuizModel::update_attempt_result( $attempt_id );
765 }
766
767 // After hook.
768 do_action( 'tutor_quiz/attempt_ended', $attempt_id, $course_id, $user_id );
769 }
770
771
772 /**
773 * Quiz attempt will be finish here
774 *
775 * @since 1.0.0
776 *
777 * @return void
778 */
779 public function finishing_quiz_attempt() {
780
781 if ( Input::post( 'tutor_action' ) !== 'tutor_finish_quiz_attempt' ) {
782 return;
783 }
784 // Checking nonce.
785 tutor_utils()->checking_nonce();
786
787 if ( ! is_user_logged_in() ) {
788 die( 'Please sign in to do this operation' );
789 }
790
791 global $wpdb;
792
793 $quiz_id = Input::post( 'quiz_id', 0, Input::TYPE_INT );
794 $attempt = tutor_utils()->is_started_quiz( $quiz_id );
795 $attempt_id = $attempt->attempt_id;
796
797 $attempt_info = array(
798 'total_answered_questions' => 0,
799 'earned_marks' => 0,
800 'attempt_status' => 'attempt_ended',
801 'attempt_ended_at' => date( 'Y-m-d H:i:s', tutor_time() ), //phpcs:ignore
802 );
803
804 do_action( 'tutor_quiz_before_finish', $attempt_id, $quiz_id, $attempt->user_id );
805 $wpdb->update( $wpdb->prefix . 'tutor_quiz_attempts', $attempt_info, array( 'attempt_id' => $attempt_id ) );
806 do_action( 'tutor_quiz_finished', $attempt_id, $quiz_id, $attempt->user_id );
807
808 wp_redirect( tutor_utils()->input_old( '_wp_http_referer' ) );
809 }
810
811 /**
812 * Get quiz total marks.
813 *
814 * @since 3.0.0
815 *
816 * @param int $quiz_id quiz id.
817 *
818 * @return int|float
819 */
820 public static function get_quiz_total_marks( $quiz_id ) {
821 global $wpdb;
822
823 $total_marks = $wpdb->get_var(
824 $wpdb->prepare(
825 "SELECT SUM(question_mark) total_marks
826 FROM {$wpdb->prefix}tutor_quiz_questions
827 WHERE quiz_id=%d",
828 $quiz_id
829 )
830 );
831
832 return floatval( $total_marks );
833 }
834
835 /**
836 * Quiz timeout by ajax
837 *
838 * @since 1.0.0
839 *
840 * @return void
841 */
842 public function tutor_quiz_timeout() {
843 tutils()->checking_nonce();
844
845 global $wpdb;
846
847 $quiz_id = Input::post( 'quiz_id', 0, Input::TYPE_INT );
848 $attempt = tutor_utils()->is_started_quiz( $quiz_id );
849
850 if ( $attempt ) {
851 $attempt_id = $attempt->attempt_id;
852
853 $data = array(
854 'attempt_status' => 'attempt_timeout',
855 'total_marks' => self::get_quiz_total_marks( $quiz_id ),
856 'earned_marks' => 0,
857 'attempt_ended_at' => gmdate( 'Y-m-d H:i:s', tutor_time() ),
858 );
859
860 $wpdb->update( $wpdb->prefix . 'tutor_quiz_attempts', $data, array( 'attempt_id' => $attempt->attempt_id ) );
861
862 do_action( 'tutor_quiz_timeout', $attempt_id, $quiz_id, $attempt->user_id );
863
864 wp_send_json_success();
865 }
866
867 wp_send_json_error( __( 'Quiz has been timeout already', 'tutor' ) );
868 }
869
870 /**
871 * Review quiz answer
872 *
873 * @since 1.0.0
874 *
875 * @return void
876 */
877 public function review_quiz_answer() {
878
879 tutor_utils()->checking_nonce();
880
881 global $wpdb;
882
883 $attempt_id = Input::post( 'attempt_id', 0, Input::TYPE_INT );
884 $context = Input::post( 'context' );
885 $attempt_answer_id = Input::post( 'attempt_answer_id', 0, Input::TYPE_INT );
886 $mark_as = Input::post( 'mark_as' );
887
888 if ( ! tutor_utils()->can_user_manage( 'attempt', $attempt_id ) || ! tutor_utils()->can_user_manage( 'attempt_answer', $attempt_answer_id ) ) {
889 wp_send_json_error( array( 'message' => __( 'Access Denied', 'tutor' ) ) );
890 }
891
892 $attempt_answer = $wpdb->get_row(
893 $wpdb->prepare(
894 "SELECT *
895 FROM {$wpdb->prefix}tutor_quiz_attempt_answers
896 WHERE attempt_answer_id = %d
897 ",
898 $attempt_answer_id
899 )
900 );
901
902 $attempt = tutor_utils()->get_attempt( $attempt_id );
903 $question = QuizModel::get_quiz_question_by_id( $attempt_answer->question_id );
904 $course_id = $attempt->course_id;
905 $student_id = $attempt->user_id;
906 $previous_ans = $attempt_answer->is_correct;
907
908 do_action( 'tutor_quiz_review_answer_before', $attempt_answer_id, $attempt_id, $mark_as );
909
910 if ( 'correct' === $mark_as ) {
911 $attempt_update_data = array();
912 $answer_update_data = array(
913 'achieved_mark' => $attempt_answer->question_mark,
914 'is_correct' => 1,
915 );
916
917 $wpdb->update( $wpdb->prefix . 'tutor_quiz_attempt_answers', $answer_update_data, array( 'attempt_answer_id' => $attempt_answer_id ) );
918
919 if ( 0 == $previous_ans || null == $previous_ans ) {
920 // if previous answer was wrong or in review then add point as correct.
921 $attempt_update_data = array(
922 'earned_marks' => $attempt->earned_marks + $attempt_answer->question_mark,
923 'is_manually_reviewed' => 1,
924 'manually_reviewed_at' => date( 'Y-m-d H:i:s', tutor_time() ), //phpcs:ignore
925 );
926 }
927
928 if ( 'open_ended' === $question->question_type || 'short_answer' === $question->question_type ) {
929 $attempt_update_data['attempt_status'] = 'attempt_ended';
930 }
931
932 if ( ! empty( $attempt_update_data ) ) {
933 $wpdb->update( $wpdb->tutor_quiz_attempts, $attempt_update_data, array( 'attempt_id' => $attempt_id ) );
934 }
935 } elseif ( 'incorrect' === $mark_as ) {
936 $attempt_update_data = array();
937 $answer_update_data = array(
938 'achieved_mark' => '0.00',
939 'is_correct' => 0,
940 );
941
942 $wpdb->update( $wpdb->prefix . 'tutor_quiz_attempt_answers', $answer_update_data, array( 'attempt_answer_id' => $attempt_answer_id ) );
943
944 if ( 1 == $previous_ans ) {
945 // If previous ans was right then mynus.
946 $attempt_update_data = array(
947 'earned_marks' => $attempt->earned_marks - $attempt_answer->question_mark,
948 'is_manually_reviewed' => 1,
949 'manually_reviewed_at' => date( 'Y-m-d H:i:s', tutor_time() ),//phpcs:ignore
950 );
951 }
952
953 if ( 'open_ended' === $question->question_type || 'short_answer' === $question->question_type ) {
954 $attempt_update_data['attempt_status'] = 'attempt_ended';
955 }
956
957 if ( ! empty( $attempt_update_data ) ) {
958 $wpdb->update( $wpdb->tutor_quiz_attempts, $attempt_update_data, array( 'attempt_id' => $attempt_id ) );
959 }
960 }
961
962 QuizModel::update_attempt_result( $attempt_id );
963
964 do_action( 'tutor_quiz_review_answer_after', $attempt_answer_id, $attempt_id, $mark_as );
965 do_action( 'tutor_quiz/answer/review/after', $attempt_answer_id, $course_id, $student_id );
966
967 ob_start();
968 tutor_load_template_from_custom_path(
969 tutor()->path . '/views/quiz/attempt-details.php',
970 array(
971 'attempt_id' => $attempt_id,
972 'user_id' => $student_id,
973 'context' => $context,
974 'back_url' => Input::post( 'back_url' ),
975 )
976 );
977 wp_send_json_success( array( 'html' => ob_get_clean() ) );
978 }
979
980 /**
981 * Do auto course complete after review a quiz attempt.
982 *
983 * @since 2.4.0
984 *
985 * @param int $attempt_answer_id attempt answer id.
986 * @param int $course_id course id.
987 * @param int $user_id student id.
988 *
989 * @return void
990 */
991 public function do_auto_course_complete( $attempt_answer_id, $course_id, $user_id ) {
992 if ( CourseModel::can_autocomplete_course( $course_id, $user_id ) ) {
993 CourseModel::mark_course_as_completed( $course_id, $user_id );
994 Course::set_review_popup_data( $user_id, $course_id );
995 }
996 }
997
998 /**
999 * Get a quiz details by id
1000 *
1001 * @return void
1002 */
1003 public function ajax_quiz_details() {
1004 tutor_utils()->check_nonce();
1005
1006 $quiz_id = Input::post( 'quiz_id', 0, Input::TYPE_INT );
1007 if ( ! tutor_utils()->can_user_manage( 'quiz', $quiz_id ) ) {
1008 $this->json_response(
1009 tutor_utils()->error_message(),
1010 null,
1011 HttpHelper::STATUS_FORBIDDEN
1012 );
1013 }
1014
1015 $data = QuizModel::get_quiz_details( $quiz_id );
1016
1017 $data = apply_filters( 'tutor_quiz_details_response', $data, $quiz_id );
1018
1019 $this->json_response(
1020 __( 'Quiz data fetched successfully', 'tutor' ),
1021 $data
1022 );
1023 }
1024
1025 /**
1026 * Delete quiz by id
1027 *
1028 * @since 1.0.0
1029 * @since 3.0.0 refactor and response change.
1030 *
1031 * @return void
1032 */
1033 public function ajax_quiz_delete() {
1034 if ( ! tutor_utils()->is_nonce_verified() ) {
1035 $this->json_response( tutor_utils()->error_message( 'nonce' ), null, HttpHelper::STATUS_BAD_REQUEST );
1036 }
1037
1038 global $wpdb;
1039
1040 $quiz_id = Input::post( 'quiz_id', 0, Input::TYPE_INT );
1041 if ( ! tutor_utils()->can_user_manage( 'quiz', $quiz_id ) ) {
1042 $this->json_response(
1043 tutor_utils()->error_message(),
1044 null,
1045 HttpHelper::STATUS_FORBIDDEN
1046 );
1047 }
1048
1049 $post = get_post( $quiz_id );
1050 if ( 'tutor_quiz' !== $post->post_type ) {
1051 $this->json_response(
1052 __( 'Invalid quiz', 'tutor' ),
1053 null,
1054 HttpHelper::STATUS_BAD_REQUEST
1055 );
1056 }
1057
1058 do_action( 'tutor_delete_quiz_before', $quiz_id );
1059
1060 $wpdb->delete( $wpdb->prefix . 'tutor_quiz_attempts', array( 'quiz_id' => $quiz_id ) );
1061 $wpdb->delete( $wpdb->prefix . 'tutor_quiz_attempt_answers', array( 'quiz_id' => $quiz_id ) );
1062
1063 $questions_ids = $wpdb->get_col( $wpdb->prepare( "SELECT question_id FROM {$wpdb->prefix}tutor_quiz_questions WHERE quiz_id = %d ", $quiz_id ) );
1064
1065 if ( is_array( $questions_ids ) && count( $questions_ids ) ) {
1066 $in_question_ids = QueryHelper::prepare_in_clause( $questions_ids );
1067 //phpcs:disable
1068 $wpdb->query(
1069 "DELETE
1070 FROM {$wpdb->prefix}tutor_quiz_question_answers
1071 WHERE belongs_question_id IN({$in_question_ids})
1072 "
1073 );
1074 //phpcs:enable
1075 }
1076
1077 $wpdb->delete( $wpdb->prefix . 'tutor_quiz_questions', array( 'quiz_id' => $quiz_id ) );
1078
1079 wp_delete_post( $quiz_id, true );
1080
1081 do_action( 'tutor_delete_quiz_after', $quiz_id );
1082
1083 $this->json_response(
1084 __( 'Quiz deleted successfully', 'tutor' ),
1085 $quiz_id
1086 );
1087 }
1088
1089 /**
1090 * Get answers by quiz id
1091 *
1092 * @since 1.0.0
1093 *
1094 * @param int $question_id question id.
1095 * @param mixed $question_type type of question.
1096 * @param boolean $is_correct only correct answers or not.
1097 *
1098 * @return wpdb:get_results
1099 */
1100 private function get_answers_by_q_id( $question_id, $question_type, $is_correct = false ) {
1101 global $wpdb;
1102
1103 $correct_clause = $is_correct ? ' AND is_correct=1 ' : '';
1104 //phpcs:disable
1105 return $wpdb->get_results(
1106 $wpdb->prepare(
1107 "SELECT * FROM {$wpdb->prefix}tutor_quiz_question_answers
1108 WHERE belongs_question_id = %d
1109 AND belongs_question_type = %s
1110 {$correct_clause}
1111 ORDER BY answer_order ASC;
1112 ",
1113 $question_id,
1114 esc_sql( $question_type )
1115 )
1116 );
1117 //phpcs:enable
1118 }
1119
1120 /**
1121 * Rendering quiz for frontend
1122 *
1123 * @since 1.0.0
1124 *
1125 * @return void send wp_json response
1126 */
1127 public function tutor_render_quiz_content() {
1128
1129 tutor_utils()->checking_nonce();
1130
1131 $quiz_id = Input::post( 'quiz_id', 0, Input::TYPE_INT );
1132
1133 if ( ! tutor_utils()->has_enrolled_content_access( 'quiz', $quiz_id ) ) {
1134 wp_send_json_error( array( 'message' => __( 'Access Denied.', 'tutor' ) ) );
1135 }
1136
1137 ob_start();
1138 global $post;
1139
1140 $post = get_post( $quiz_id ); //phpcs:ignore
1141 setup_postdata( $post );
1142
1143 single_quiz_contents();
1144 wp_reset_postdata();
1145
1146 $html = ob_get_clean();
1147 wp_send_json_success( array( 'html' => $html ) );
1148 }
1149
1150 /**
1151 * Get attempt details
1152 *
1153 * @since 1.0.0
1154 *
1155 * @param int $attempt_id required attempt id to get details.
1156 *
1157 * @return mixed object on success, null on failure
1158 */
1159 public static function attempt_details( int $attempt_id ) {
1160 global $wpdb;
1161 $attempt_details = $wpdb->get_row(
1162 $wpdb->prepare(
1163 "SELECT *
1164 FROM {$wpdb->prefix}tutor_quiz_attempts
1165 WHERE attempt_id = %d
1166 ",
1167 $attempt_id
1168 )
1169 );
1170 return $attempt_details;
1171 }
1172
1173 /**
1174 * Update quiz attempt info
1175 *
1176 * @since 1.0.0
1177 *
1178 * @param int $attempt_id attempt id.
1179 * @param mixed $attempt_info serialize data.
1180 *
1181 * @return bool, true on success, false on failure
1182 */
1183 public static function update_attempt_info( int $attempt_id, $attempt_info ) {
1184 global $wpdb;
1185 $table = $wpdb->prefix . 'tutor_quiz_attempts';
1186 $update_info = $wpdb->update(
1187 $table,
1188 array( 'attempt_info' => $attempt_info ),
1189 array( 'attempt_id' => $attempt_id )
1190 );
1191 return $update_info ? true : false;
1192 }
1193
1194 /**
1195 * Attempt delete ajax request handler
1196 *
1197 * @since 2.1.0
1198 *
1199 * @return void wp_json response
1200 */
1201 public function attempt_delete() {
1202 tutor_utils()->checking_nonce();
1203
1204 $attempt_id = Input::post( 'id', 0, Input::TYPE_INT );
1205 $attempt = tutor_utils()->get_attempt( $attempt_id );
1206 if ( ! $attempt ) {
1207 wp_send_json_error( __( 'Invalid attempt ID', 'tutor' ) );
1208 }
1209
1210 $user_id = get_current_user_id();
1211 $course_id = $attempt->course_id;
1212
1213 if ( tutor_utils()->can_user_edit_course( $user_id, $course_id ) ) {
1214 QuizModel::delete_quiz_attempt( $attempt_id );
1215 wp_send_json_success( __( 'Attempt deleted successfully!', 'tutor' ) );
1216 } else {
1217 wp_send_json_error( tutor_utils()->error_message() );
1218 }
1219 }
1220
1221 /**
1222 * Get all quiz attempts for a user in a specific course.
1223 *
1224 * @since 3.8.1
1225 *
1226 * @param int $course_id The ID of the course.
1227 * @param int $user_id The ID of the user.
1228 *
1229 * @return array Returns an array of quiz attempt objects with their answers, or an empty array on error.
1230 */
1231 public function get_quiz_attempts_and_answers_by_course_id( int $course_id ): array {
1232 global $wpdb;
1233
1234 $results = QueryHelper::get_all( $wpdb->tutor_quiz_attempts, array( 'course_id' => $course_id ), 'course_id', -1 );
1235
1236 if ( empty( $results ) ) {
1237 return array();
1238 }
1239
1240 return array_map(
1241 function ( $item ) {
1242 $item->quiz_attempt_answers = $this->get_quiz_attempt_answers_by_attempt_id( $item->attempt_id );
1243 return $item;
1244 },
1245 $results
1246 );
1247 }
1248
1249 /**
1250 * Get all quiz attempt answers for a specific quiz attempt.
1251 *
1252 * @since 3.8.1
1253 *
1254 * @param int $attempt_id The ID of the quiz attempt.
1255 *
1256 * @return array Returns an array of quiz attempt answers objects, or an empty array on error.
1257 */
1258 private function get_quiz_attempt_answers_by_attempt_id( int $attempt_id ): array {
1259 global $wpdb;
1260
1261 $results = QueryHelper::get_all( $wpdb->tutor_quiz_attempt_answers, array( 'quiz_attempt_id' => $attempt_id ), 'quiz_attempt_id', -1 );
1262
1263 if ( empty( $results ) ) {
1264 return array();
1265 }
1266
1267 return $results;
1268 }
1269 }
1270