PluginProbe ʕ •ᴥ•ʔ
Tutor LMS – eLearning and online course solution / 3.7.3
Tutor LMS – eLearning and online course solution v3.7.3
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 11 months ago
Quiz.php
1215 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_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