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