PluginProbe ʕ •ᴥ•ʔ
Tutor LMS – eLearning and online course solution / 3.7.1
Tutor LMS – eLearning and online course solution v3.7.1
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 11 months ago Ajax.php 1 year ago Announcements.php 1 year ago Assets.php 11 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 10 months ago Course_Embed.php 3 years ago Course_Filter.php 1 year ago Course_List.php 10 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 1 year ago FormHandler.php 2 years ago Frontend.php 1 year ago Gutenberg.php 1 year ago Icon.php 10 months ago Input.php 1 year ago Instructor.php 1 year ago Instructors_List.php 11 months ago Lesson.php 10 months ago Options_V2.php 11 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 10 months ago QuizBuilder.php 11 months ago Quiz_Attempts_List.php 11 months ago RestAPI.php 2 years ago Reviews.php 11 months ago Rewrite_Rules.php 2 years ago Shortcode.php 1 year ago Singleton.php 1 year ago Student.php 1 year ago Students_List.php 1 year ago Taxonomies.php 1 year ago Template.php 11 months ago Theme_Compatibility.php 3 years ago Tools.php 1 year ago Tools_V2.php 1 year ago Tutor.php 10 months ago TutorEDD.php 1 year ago Tutor_Base.php 2 years ago Tutor_Setup.php 1 year ago Upgrader.php 10 months ago User.php 1 year ago Utils.php 10 months ago Video_Stream.php 3 years ago WhatsNew.php 2 years ago Withdraw.php 1 year ago Withdraw_Requests_List.php 11 months ago WooCommerce.php 1 year ago
Quiz.php
1212 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 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_attemp_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_attemp_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_attemp_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 // Sanitize data by helper method.
474 $attempt_answers = isset( $_POST['attempt'] ) ? tutor_sanitize_data( $_POST['attempt'] ) : false; //phpcs:ignore
475 $attempt_answers = is_array( $attempt_answers ) ? $attempt_answers : array();
476
477 // Check if has access to the attempt.
478 if ( ! $attempt || $user_id != $attempt->user_id ) {
479 die( 'Operation not allowed, attempt not found or permission denied' );
480 }
481 self::manage_attempt_answers( $attempt_answers, $attempt, $attempt_id, $course_id, $user_id );
482 return true;
483 }
484
485 /**
486 * Manage attempt answers
487 *
488 * Evaluate each attempt answer and update the attempts table & insert in the attempt_answers table.
489 *
490 * @since 2.6.1
491 *
492 * @param array $attempt_answers attempt answers.
493 * @param object $attempt single attempt.
494 * @param int $attempt_id attempt id.
495 * @param int $course_id course id.
496 * @param int $user_id user id.
497 *
498 * @return void
499 */
500 public static function manage_attempt_answers( $attempt_answers, $attempt, $attempt_id, $course_id, $user_id ) {
501 global $wpdb;
502 // Before hook.
503 do_action( 'tutor_quiz/attempt_analysing/before', $attempt_id );
504
505 // Single quiz can have multiple question. So multiple answer should be saved.
506 foreach ( $attempt_answers as $attempt_id => $attempt_answer ) {
507 // Get total marks of all question comes.
508 $question_ids = tutor_utils()->avalue_dot( 'quiz_question_ids', $attempt_answer );
509 $question_ids = array_filter(
510 $question_ids,
511 function( $id ) {
512 return (int) $id;
513 }
514 );
515
516 // Calculate and set the total marks in attempt table for this question.
517 if ( is_array( $question_ids ) && count( $question_ids ) ) {
518 $question_ids_string = QueryHelper::prepare_in_clause( $question_ids );
519
520 // Get total marks of the questions from question table.
521 //phpcs:disable
522 $query = $wpdb->prepare(
523 "SELECT SUM(question_mark)
524 FROM {$wpdb->prefix}tutor_quiz_questions
525 WHERE 1 = %d
526 AND question_id IN({$question_ids_string});
527 ",
528 1
529 );
530 $total_question_marks = $wpdb->get_var( $query );
531 //phpcs:enable
532
533 $total_question_marks = apply_filters( 'tutor_filter_update_before_question_mark', $total_question_marks, $question_ids, $user_id, $attempt_id );
534
535 // Set the the total mark in the attempt table for the question.
536 $wpdb->update(
537 $wpdb->prefix . 'tutor_quiz_attempts',
538 array( 'total_marks' => $total_question_marks ),
539 array( 'attempt_id' => $attempt_id )
540 );
541 }
542
543 $total_marks = 0;
544 $review_required = false;
545 $quiz_answers = tutor_utils()->avalue_dot( 'quiz_question', $attempt_answer );
546
547 if ( tutor_utils()->count( $quiz_answers ) ) {
548
549 foreach ( $quiz_answers as $question_id => $answers ) {
550 $question = QuizModel::get_quiz_question_by_id( $question_id );
551 $question_type = $question->question_type;
552
553 $is_answer_was_correct = false;
554 $given_answer = '';
555
556 if ( 'true_false' === $question_type || 'single_choice' === $question_type ) {
557
558 if ( ! is_numeric( $answers ) || ! $answers ) {
559 wp_send_json_error();
560 exit;
561 }
562
563 $given_answer = $answers;
564 $is_answer_was_correct = (bool) $wpdb->get_var(
565 $wpdb->prepare(
566 "SELECT is_correct
567 FROM {$wpdb->prefix}tutor_quiz_question_answers
568 WHERE answer_id = %d
569 ",
570 $answers
571 )
572 );
573
574 } elseif ( 'multiple_choice' === $question_type ) {
575
576 $given_answer = (array) ( $answers );
577
578 $given_answer = array_filter(
579 $given_answer,
580 function( $id ) {
581 return is_numeric( $id ) && $id > 0;
582 }
583 );
584 $get_original_answers = (array) $wpdb->get_col(
585 $wpdb->prepare(
586 "SELECT
587 answer_id
588 FROM
589 {$wpdb->prefix}tutor_quiz_question_answers
590 WHERE belongs_question_id = %d
591 AND belongs_question_type = %s
592 AND is_correct = 1 ;
593 ",
594 $question->question_id,
595 $question_type
596 )
597 );
598
599 if ( count( array_diff( $get_original_answers, $given_answer ) ) === 0 && count( $get_original_answers ) === count( $given_answer ) ) {
600 $is_answer_was_correct = true;
601 }
602 $given_answer = maybe_serialize( $answers );
603
604 } elseif ( 'fill_in_the_blank' === $question_type ) {
605
606 $get_original_answer = $wpdb->get_row(
607 $wpdb->prepare(
608 "SELECT *
609 FROM {$wpdb->prefix}tutor_quiz_question_answers
610 WHERE belongs_question_id = %d
611 AND belongs_question_type = %s ;
612 ",
613 $question->question_id,
614 $question_type
615 )
616 );
617
618 /**
619 * Answers stored in DB
620 */
621 $gap_answer = (array) explode( '|', $get_original_answer->answer_two_gap_match );
622 $gap_answer = maybe_serialize(
623 array_map(
624 function ( $ans ) {
625 return wp_slash( trim( $ans ) );
626 },
627 $gap_answer
628 )
629 );
630
631 /**
632 * Answers from user input
633 */
634 $given_answer = (array) array_map( 'sanitize_text_field', $answers );
635 $given_answer = maybe_serialize( $given_answer );
636
637 /**
638 * Compare answer's by making both case-insensitive.
639 */
640 if ( strtolower( $given_answer ) == strtolower( $gap_answer ) ) {
641 $is_answer_was_correct = true;
642 }
643 } elseif ( 'open_ended' === $question_type || 'short_answer' === $question_type ) {
644 $review_required = true;
645 $given_answer = wp_kses_post( $answers );
646
647 } elseif ( 'ordering' === $question_type || 'matching' === $question_type || 'image_matching' === $question_type ) {
648
649 $given_answer = (array) array_map( 'sanitize_text_field', tutor_utils()->avalue_dot( 'answers', $answers ) );
650 $given_answer = maybe_serialize( $given_answer );
651
652 $get_original_answers = (array) $wpdb->get_col(
653 $wpdb->prepare(
654 "SELECT answer_id
655 FROM {$wpdb->prefix}tutor_quiz_question_answers
656 WHERE belongs_question_id = %d
657 AND belongs_question_type = %s
658 ORDER BY answer_order ASC ;
659 ",
660 $question->question_id,
661 $question_type
662 )
663 );
664
665 $get_original_answers = array_map( 'sanitize_text_field', $get_original_answers );
666
667 if ( maybe_serialize( $get_original_answers ) == $given_answer ) {
668 $is_answer_was_correct = true;
669 }
670 } elseif ( 'image_answering' === $question_type ) {
671 $image_inputs = tutor_utils()->avalue_dot( 'answer_id', $answers );
672 $image_inputs = (array) array_map( 'sanitize_text_field', $image_inputs );
673 $given_answer = maybe_serialize( $image_inputs );
674 $is_answer_was_correct = false;
675 /**
676 * For the image_answering question type result
677 * remain pending in spite of correct answer & required
678 * review of admin/instructor. Since it's
679 * pending we need to mark it as incorrect. Otherwise if
680 * mark it correct then earned mark will be updated. then
681 * again when instructor/admin review & mark it as correct
682 * extra mark is adding. In this case, student
683 * getting double mark for the same question.
684 *
685 * For now code is commenting will be removed later on
686 *
687 * @since 2.1.5
688 */
689
690 //phpcs:disable
691
692 // $db_answer = $wpdb->get_col(
693 // $wpdb->prepare(
694 // "SELECT answer_title
695 // FROM {$wpdb->prefix}tutor_quiz_question_answers
696 // WHERE belongs_question_id = %d
697 // AND belongs_question_type = 'image_answering'
698 // ORDER BY answer_order asc ;",
699 // $question_id
700 // )
701 // );
702
703 // if ( is_array( $db_answer ) && count( $db_answer ) ) {
704 // $is_answer_was_correct = ( strtolower( maybe_serialize( array_values( $image_inputs ) ) ) == strtolower( maybe_serialize( $db_answer ) ) );
705 // }
706 //phpcs:enable
707 }
708
709 $question_mark = $is_answer_was_correct ? $question->question_mark : 0;
710 $total_marks += $question_mark;
711
712 $total_marks = apply_filters( 'tutor_filter_quiz_total_marks', $total_marks, $question_id, $question_type, $user_id, $attempt_id );
713
714 $answers_data = array(
715 'user_id' => $user_id,
716 'quiz_id' => $attempt->quiz_id,
717 'question_id' => $question_id,
718 'quiz_attempt_id' => $attempt_id,
719 'given_answer' => $given_answer,
720 'question_mark' => $question->question_mark,
721 'achieved_mark' => $question_mark,
722 'minus_mark' => 0,
723 'is_correct' => $is_answer_was_correct ? 1 : 0,
724 );
725
726 /**
727 * Check if question_type open ended or short ans the set
728 * is_correct default value null before saving
729 */
730 if ( in_array( $question_type, array( 'open_ended', 'short_answer', 'image_answering' ) ) ) {
731 $answers_data['is_correct'] = null;
732 $review_required = true;
733 }
734
735 $answers_data = apply_filters( 'tutor_filter_quiz_answer_data', $answers_data, $question_id, $question_type, $user_id, $attempt_id );
736
737 $wpdb->insert( $wpdb->prefix . 'tutor_quiz_attempt_answers', $answers_data );
738 }
739 }
740
741 $attempt_info = array(
742 'total_answered_questions' => tutor_utils()->count( $quiz_answers ),
743 'earned_marks' => $total_marks,
744 'attempt_status' => 'attempt_ended',
745 'attempt_ended_at' => date( 'Y-m-d H:i:s', tutor_time() ), //phpcs:ignore
746 );
747
748 if ( $review_required ) {
749 $attempt_info['attempt_status'] = 'review_required';
750 }
751
752 $wpdb->update( $wpdb->tutor_quiz_attempts, $attempt_info, array( 'attempt_id' => $attempt_id ) );
753
754 QuizModel::update_attempt_result( $attempt_id );
755 }
756
757 // After hook.
758 do_action( 'tutor_quiz/attempt_ended', $attempt_id, $course_id, $user_id );
759 }
760
761
762 /**
763 * Quiz attempt will be finish here
764 *
765 * @since 1.0.0
766 *
767 * @return void
768 */
769 public function finishing_quiz_attempt() {
770
771 if ( Input::post( 'tutor_action' ) !== 'tutor_finish_quiz_attempt' ) {
772 return;
773 }
774 // Checking nonce.
775 tutor_utils()->checking_nonce();
776
777 if ( ! is_user_logged_in() ) {
778 die( 'Please sign in to do this operation' );
779 }
780
781 global $wpdb;
782
783 $quiz_id = Input::post( 'quiz_id', 0, Input::TYPE_INT );
784 $attempt = tutor_utils()->is_started_quiz( $quiz_id );
785 $attempt_id = $attempt->attempt_id;
786
787 $attempt_info = array(
788 'total_answered_questions' => 0,
789 'earned_marks' => 0,
790 'attempt_status' => 'attempt_ended',
791 'attempt_ended_at' => date( 'Y-m-d H:i:s', tutor_time() ), //phpcs:ignore
792 );
793
794 do_action( 'tutor_quiz_before_finish', $attempt_id, $quiz_id, $attempt->user_id );
795 $wpdb->update( $wpdb->prefix . 'tutor_quiz_attempts', $attempt_info, array( 'attempt_id' => $attempt_id ) );
796 do_action( 'tutor_quiz_finished', $attempt_id, $quiz_id, $attempt->user_id );
797
798 wp_redirect( tutor_utils()->input_old( '_wp_http_referer' ) );
799 }
800
801 /**
802 * Get quiz total marks.
803 *
804 * @since 3.0.0
805 *
806 * @param int $quiz_id quiz id.
807 *
808 * @return int|float
809 */
810 public static function get_quiz_total_marks( $quiz_id ) {
811 global $wpdb;
812
813 $total_marks = $wpdb->get_var(
814 $wpdb->prepare(
815 "SELECT SUM(question_mark) total_marks
816 FROM {$wpdb->prefix}tutor_quiz_questions
817 WHERE quiz_id=%d",
818 $quiz_id
819 )
820 );
821
822 return floatval( $total_marks );
823 }
824
825 /**
826 * Quiz timeout by ajax
827 *
828 * @since 1.0.0
829 *
830 * @return void
831 */
832 public function tutor_quiz_timeout() {
833 tutils()->checking_nonce();
834
835 global $wpdb;
836
837 $quiz_id = Input::post( 'quiz_id', 0, Input::TYPE_INT );
838 $attempt = tutor_utils()->is_started_quiz( $quiz_id );
839
840 if ( $attempt ) {
841 $attempt_id = $attempt->attempt_id;
842
843 $data = array(
844 'attempt_status' => 'attempt_timeout',
845 'total_marks' => self::get_quiz_total_marks( $quiz_id ),
846 'earned_marks' => 0,
847 'attempt_ended_at' => gmdate( 'Y-m-d H:i:s', tutor_time() ),
848 );
849
850 $wpdb->update( $wpdb->prefix . 'tutor_quiz_attempts', $data, array( 'attempt_id' => $attempt->attempt_id ) );
851
852 do_action( 'tutor_quiz_timeout', $attempt_id, $quiz_id, $attempt->user_id );
853
854 wp_send_json_success();
855 }
856
857 wp_send_json_error( __( 'Quiz has been timeout already', 'tutor' ) );
858 }
859
860 /**
861 * Review quiz answer
862 *
863 * @since 1.0.0
864 *
865 * @return void
866 */
867 public function review_quiz_answer() {
868
869 tutor_utils()->checking_nonce();
870
871 global $wpdb;
872
873 $attempt_id = Input::post( 'attempt_id', 0, Input::TYPE_INT );
874 $context = Input::post( 'context' );
875 $attempt_answer_id = Input::post( 'attempt_answer_id', 0, Input::TYPE_INT );
876 $mark_as = Input::post( 'mark_as' );
877
878 if ( ! tutor_utils()->can_user_manage( 'attempt', $attempt_id ) || ! tutor_utils()->can_user_manage( 'attempt_answer', $attempt_answer_id ) ) {
879 wp_send_json_error( array( 'message' => __( 'Access Denied', 'tutor' ) ) );
880 }
881
882 $attempt_answer = $wpdb->get_row(
883 $wpdb->prepare(
884 "SELECT *
885 FROM {$wpdb->prefix}tutor_quiz_attempt_answers
886 WHERE attempt_answer_id = %d
887 ",
888 $attempt_answer_id
889 )
890 );
891
892 $attempt = tutor_utils()->get_attempt( $attempt_id );
893 $question = QuizModel::get_quiz_question_by_id( $attempt_answer->question_id );
894 $course_id = $attempt->course_id;
895 $student_id = $attempt->user_id;
896 $previous_ans = $attempt_answer->is_correct;
897
898 do_action( 'tutor_quiz_review_answer_before', $attempt_answer_id, $attempt_id, $mark_as );
899
900 if ( 'correct' === $mark_as ) {
901 $attempt_update_data = array();
902 $answer_update_data = array(
903 'achieved_mark' => $attempt_answer->question_mark,
904 'is_correct' => 1,
905 );
906
907 $wpdb->update( $wpdb->prefix . 'tutor_quiz_attempt_answers', $answer_update_data, array( 'attempt_answer_id' => $attempt_answer_id ) );
908
909 if ( 0 == $previous_ans || null == $previous_ans ) {
910 // if previous answer was wrong or in review then add point as correct.
911 $attempt_update_data = array(
912 'earned_marks' => $attempt->earned_marks + $attempt_answer->question_mark,
913 'is_manually_reviewed' => 1,
914 'manually_reviewed_at' => date( 'Y-m-d H:i:s', tutor_time() ), //phpcs:ignore
915 );
916 }
917
918 if ( 'open_ended' === $question->question_type || 'short_answer' === $question->question_type ) {
919 $attempt_update_data['attempt_status'] = 'attempt_ended';
920 }
921
922 if ( ! empty( $attempt_update_data ) ) {
923 $wpdb->update( $wpdb->tutor_quiz_attempts, $attempt_update_data, array( 'attempt_id' => $attempt_id ) );
924 }
925 } elseif ( 'incorrect' === $mark_as ) {
926 $attempt_update_data = array();
927 $answer_update_data = array(
928 'achieved_mark' => '0.00',
929 'is_correct' => 0,
930 );
931
932 $wpdb->update( $wpdb->prefix . 'tutor_quiz_attempt_answers', $answer_update_data, array( 'attempt_answer_id' => $attempt_answer_id ) );
933
934 if ( 1 == $previous_ans ) {
935 // If previous ans was right then mynus.
936 $attempt_update_data = array(
937 'earned_marks' => $attempt->earned_marks - $attempt_answer->question_mark,
938 'is_manually_reviewed' => 1,
939 'manually_reviewed_at' => date( 'Y-m-d H:i:s', tutor_time() ),//phpcs:ignore
940 );
941 }
942
943 if ( 'open_ended' === $question->question_type || 'short_answer' === $question->question_type ) {
944 $attempt_update_data['attempt_status'] = 'attempt_ended';
945 }
946
947 if ( ! empty( $attempt_update_data ) ) {
948 $wpdb->update( $wpdb->tutor_quiz_attempts, $attempt_update_data, array( 'attempt_id' => $attempt_id ) );
949 }
950 }
951
952 QuizModel::update_attempt_result( $attempt_id );
953
954 do_action( 'tutor_quiz_review_answer_after', $attempt_answer_id, $attempt_id, $mark_as );
955 do_action( 'tutor_quiz/answer/review/after', $attempt_answer_id, $course_id, $student_id );
956
957 ob_start();
958 tutor_load_template_from_custom_path(
959 tutor()->path . '/views/quiz/attempt-details.php',
960 array(
961 'attempt_id' => $attempt_id,
962 'user_id' => $student_id,
963 'context' => $context,
964 'back_url' => Input::post( 'back_url' ),
965 )
966 );
967 wp_send_json_success( array( 'html' => ob_get_clean() ) );
968 }
969
970 /**
971 * Do auto course complete after review a quiz attempt.
972 *
973 * @since 2.4.0
974 *
975 * @param int $attempt_answer_id attempt answer id.
976 * @param int $course_id course id.
977 * @param int $user_id student id.
978 *
979 * @return void
980 */
981 public function do_auto_course_complete( $attempt_answer_id, $course_id, $user_id ) {
982 if ( CourseModel::can_autocomplete_course( $course_id, $user_id ) ) {
983 CourseModel::mark_course_as_completed( $course_id, $user_id );
984 Course::set_review_popup_data( $user_id, $course_id );
985 }
986 }
987
988 /**
989 * Get a quiz details by id
990 *
991 * @return void
992 */
993 public function ajax_quiz_details() {
994 tutor_utils()->check_nonce();
995
996 $quiz_id = Input::post( 'quiz_id', 0, Input::TYPE_INT );
997 if ( ! tutor_utils()->can_user_manage( 'quiz', $quiz_id ) ) {
998 $this->json_response(
999 tutor_utils()->error_message(),
1000 null,
1001 HttpHelper::STATUS_FORBIDDEN
1002 );
1003 }
1004
1005 $data = QuizModel::get_quiz_details( $quiz_id );
1006
1007 $data = apply_filters( 'tutor_quiz_details_response', $data, $quiz_id );
1008
1009 $this->json_response(
1010 __( 'Quiz data fetched successfully', 'tutor' ),
1011 $data
1012 );
1013 }
1014
1015 /**
1016 * Delete quiz by id
1017 *
1018 * @since 1.0.0
1019 * @since 3.0.0 refactor and response change.
1020 *
1021 * @return void
1022 */
1023 public function ajax_quiz_delete() {
1024 if ( ! tutor_utils()->is_nonce_verified() ) {
1025 $this->json_response( tutor_utils()->error_message( 'nonce' ), null, HttpHelper::STATUS_BAD_REQUEST );
1026 }
1027
1028 global $wpdb;
1029
1030 $quiz_id = Input::post( 'quiz_id', 0, Input::TYPE_INT );
1031 if ( ! tutor_utils()->can_user_manage( 'quiz', $quiz_id ) ) {
1032 $this->json_response(
1033 tutor_utils()->error_message(),
1034 null,
1035 HttpHelper::STATUS_FORBIDDEN
1036 );
1037 }
1038
1039 $post = get_post( $quiz_id );
1040 if ( 'tutor_quiz' !== $post->post_type ) {
1041 $this->json_response(
1042 __( 'Invalid quiz', 'tutor' ),
1043 null,
1044 HttpHelper::STATUS_BAD_REQUEST
1045 );
1046 }
1047
1048 do_action( 'tutor_delete_quiz_before', $quiz_id );
1049
1050 $wpdb->delete( $wpdb->prefix . 'tutor_quiz_attempts', array( 'quiz_id' => $quiz_id ) );
1051 $wpdb->delete( $wpdb->prefix . 'tutor_quiz_attempt_answers', array( 'quiz_id' => $quiz_id ) );
1052
1053 $questions_ids = $wpdb->get_col( $wpdb->prepare( "SELECT question_id FROM {$wpdb->prefix}tutor_quiz_questions WHERE quiz_id = %d ", $quiz_id ) );
1054
1055 if ( is_array( $questions_ids ) && count( $questions_ids ) ) {
1056 $in_question_ids = QueryHelper::prepare_in_clause( $questions_ids );
1057 //phpcs:disable
1058 $wpdb->query(
1059 "DELETE
1060 FROM {$wpdb->prefix}tutor_quiz_question_answers
1061 WHERE belongs_question_id IN({$in_question_ids})
1062 "
1063 );
1064 //phpcs:enable
1065 }
1066
1067 $wpdb->delete( $wpdb->prefix . 'tutor_quiz_questions', array( 'quiz_id' => $quiz_id ) );
1068
1069 wp_delete_post( $quiz_id, true );
1070
1071 do_action( 'tutor_delete_quiz_after', $quiz_id );
1072
1073 $this->json_response(
1074 __( 'Quiz deleted successfully', 'tutor' ),
1075 $quiz_id
1076 );
1077 }
1078
1079 /**
1080 * Get answers by quiz id
1081 *
1082 * @since 1.0.0
1083 *
1084 * @param int $question_id question id.
1085 * @param mixed $question_type type of question.
1086 * @param boolean $is_correct only correct answers or not.
1087 *
1088 * @return wpdb:get_results
1089 */
1090 private function get_answers_by_q_id( $question_id, $question_type, $is_correct = false ) {
1091 global $wpdb;
1092
1093 $correct_clause = $is_correct ? ' AND is_correct=1 ' : '';
1094 //phpcs:disable
1095 return $wpdb->get_results(
1096 $wpdb->prepare(
1097 "SELECT * FROM {$wpdb->prefix}tutor_quiz_question_answers
1098 WHERE belongs_question_id = %d
1099 AND belongs_question_type = %s
1100 {$correct_clause}
1101 ORDER BY answer_order ASC;
1102 ",
1103 $question_id,
1104 esc_sql( $question_type )
1105 )
1106 );
1107 //phpcs:enable
1108 }
1109
1110 /**
1111 * Rendering quiz for frontend
1112 *
1113 * @since 1.0.0
1114 *
1115 * @return void send wp_json response
1116 */
1117 public function tutor_render_quiz_content() {
1118
1119 tutor_utils()->checking_nonce();
1120
1121 $quiz_id = Input::post( 'quiz_id', 0, Input::TYPE_INT );
1122
1123 if ( ! tutor_utils()->has_enrolled_content_access( 'quiz', $quiz_id ) ) {
1124 wp_send_json_error( array( 'message' => __( 'Access Denied.', 'tutor' ) ) );
1125 }
1126
1127 ob_start();
1128 global $post;
1129
1130 $post = get_post( $quiz_id ); //phpcs:ignore
1131 setup_postdata( $post );
1132
1133 single_quiz_contents();
1134 wp_reset_postdata();
1135
1136 $html = ob_get_clean();
1137 wp_send_json_success( array( 'html' => $html ) );
1138 }
1139
1140 /**
1141 * Get attempt details
1142 *
1143 * @since 1.0.0
1144 *
1145 * @param int $attempt_id required attempt id to get details.
1146 *
1147 * @return mixed object on success, null on failure
1148 */
1149 public static function attempt_details( int $attempt_id ) {
1150 global $wpdb;
1151 $attempt_details = $wpdb->get_row(
1152 $wpdb->prepare(
1153 "SELECT *
1154 FROM {$wpdb->prefix}tutor_quiz_attempts
1155 WHERE attempt_id = %d
1156 ",
1157 $attempt_id
1158 )
1159 );
1160 return $attempt_details;
1161 }
1162
1163 /**
1164 * Update quiz attempt info
1165 *
1166 * @since 1.0.0
1167 *
1168 * @param int $attempt_id attempt id.
1169 * @param mixed $attempt_info serialize data.
1170 *
1171 * @return bool, true on success, false on failure
1172 */
1173 public static function update_attempt_info( int $attempt_id, $attempt_info ) {
1174 global $wpdb;
1175 $table = $wpdb->prefix . 'tutor_quiz_attempts';
1176 $update_info = $wpdb->update(
1177 $table,
1178 array( 'attempt_info' => $attempt_info ),
1179 array( 'attempt_id' => $attempt_id )
1180 );
1181 return $update_info ? true : false;
1182 }
1183
1184 /**
1185 * Attempt delete ajax request handler
1186 *
1187 * @since 2.1.0
1188 *
1189 * @return void wp_json response
1190 */
1191 public function attempt_delete() {
1192 tutor_utils()->checking_nonce();
1193
1194 $attempt_id = Input::post( 'id', 0, Input::TYPE_INT );
1195 $attempt = tutor_utils()->get_attempt( $attempt_id );
1196 if ( ! $attempt ) {
1197 wp_send_json_error( __( 'Invalid attempt ID', 'tutor' ) );
1198 }
1199
1200 $user_id = get_current_user_id();
1201 $course_id = $attempt->course_id;
1202
1203 if ( tutor_utils()->can_user_edit_course( $user_id, $course_id ) ) {
1204 QuizModel::delete_quiz_attempt( $attempt_id );
1205 wp_send_json_success( __( 'Attempt deleted successfully!', 'tutor' ) );
1206 } else {
1207 wp_send_json_error( tutor_utils()->error_message() );
1208 }
1209 }
1210
1211 }
1212